├── .editorconfig
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── composeApp
├── src
│ ├── androidMain
│ │ ├── res
│ │ │ └── values
│ │ │ │ └── styles.xml
│ │ ├── kotlin
│ │ │ └── com
│ │ │ │ └── paligot
│ │ │ │ └── jsonforms
│ │ │ │ └── kotlin
│ │ │ │ └── android
│ │ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
│ ├── commonMain
│ │ ├── composeResources
│ │ │ └── drawable
│ │ │ │ ├── de.svg
│ │ │ │ └── fr.svg
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── paligot
│ │ │ └── jsonforms
│ │ │ └── kotlin
│ │ │ ├── panes
│ │ │ ├── address
│ │ │ │ ├── AddressUiModel.kt
│ │ │ │ ├── AddressFormVM.kt
│ │ │ │ ├── geocode
│ │ │ │ │ ├── GeocodeNet.kt
│ │ │ │ │ └── GeocodeApi.kt
│ │ │ │ └── AddressFormPane.kt
│ │ │ └── FormListPane.kt
│ │ │ ├── App.kt
│ │ │ ├── ui
│ │ │ ├── FormScaffold.kt
│ │ │ └── MyApplicationTheme.kt
│ │ │ └── FormDescriptionNavGraph.kt
│ └── desktopMain
│ │ └── kotlin
│ │ └── com
│ │ └── paligot
│ │ └── jsonforms
│ │ └── kotlin
│ │ └── desktop
│ │ └── main.kt
└── build.gradle.kts
├── renovate.json
├── .gitignore
├── .github
├── ci-gradle.properties
└── workflows
│ ├── publish.yaml
│ ├── publish-docs.yaml
│ └── build.yaml
├── shared
├── src
│ ├── commonMain
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── paligot
│ │ │ └── jsonforms
│ │ │ └── kotlin
│ │ │ ├── models
│ │ │ ├── uischema
│ │ │ │ ├── Orientation.kt
│ │ │ │ ├── Rule.kt
│ │ │ │ ├── LayoutOptions.kt
│ │ │ │ ├── Condition.kt
│ │ │ │ ├── Effect.kt
│ │ │ │ ├── HorizontalLayout.kt
│ │ │ │ ├── VerticalLayout.kt
│ │ │ │ ├── UiSchema.kt
│ │ │ │ ├── Format.kt
│ │ │ │ ├── Control.kt
│ │ │ │ ├── GroupLayout.kt
│ │ │ │ └── ControlOptions.kt
│ │ │ ├── schema
│ │ │ │ ├── Schema.kt
│ │ │ │ ├── BooleanProperty.kt
│ │ │ │ ├── NumberProperty.kt
│ │ │ │ ├── ArrayProperty.kt
│ │ │ │ ├── Property.kt
│ │ │ │ ├── StringProperty.kt
│ │ │ │ └── ObjectProperty.kt
│ │ │ └── serializers
│ │ │ │ ├── RegexSerializer.kt
│ │ │ │ ├── ImmutableListSerializer.kt
│ │ │ │ ├── ImmutableMapSerializer.kt
│ │ │ │ └── ObjectPropertyListSerializer.kt
│ │ │ ├── internal
│ │ │ ├── checks
│ │ │ │ ├── BooleanProperty.ext.kt
│ │ │ │ ├── ValidationCheck.kt
│ │ │ │ ├── NumberProperty.ext.kt
│ │ │ │ ├── StringProperty.ext.kt
│ │ │ │ └── Property.ext.kt
│ │ │ ├── ext
│ │ │ │ ├── BooleanProperty.ext.kt
│ │ │ │ ├── Control.ext.kt
│ │ │ │ ├── JsonPrimitive.ext.kt
│ │ │ │ ├── StringProperty.ext.kt
│ │ │ │ └── Rule.ext.kt
│ │ │ ├── queries
│ │ │ │ ├── UiSchema.ext.kt
│ │ │ │ ├── ObjectProperty.ext.kt
│ │ │ │ └── Control.ext.kt
│ │ │ └── FieldError.kt
│ │ │ └── SchemaProvider.kt
│ └── commonTest
│ │ └── kotlin
│ │ └── com
│ │ └── paligot
│ │ └── jsonforms
│ │ └── kotlin
│ │ ├── internal
│ │ ├── checks
│ │ │ ├── BooleanPropertyValidateTest.kt
│ │ │ ├── StringPropertyValidateTest.kt
│ │ │ ├── NumberPropertyValidateTest.kt
│ │ │ ├── PropertyValidatePropertyTest.kt
│ │ │ └── ObjectPropertyIsRequiredTest.kt
│ │ ├── ext
│ │ │ ├── BooleanPropertyIsToggleTest.kt
│ │ │ ├── StringPropertyIsDropdownTest.kt
│ │ │ ├── ControlPropertyPathTest.kt
│ │ │ ├── StringPropertyIsPhoneTest.kt
│ │ │ ├── StringPropertyIsEmailTest.kt
│ │ │ ├── StringPropertyIsPasswordTest.kt
│ │ │ ├── ControlPropertyKeyTest.kt
│ │ │ ├── PropertyLabelTest.kt
│ │ │ ├── PropertyMaxCounterTest.kt
│ │ │ ├── StringPropertyIsRadioTest.kt
│ │ │ ├── PropertyIsEnabledTest.kt
│ │ │ └── RuleEvaluateEnabledTest.kt
│ │ └── queries
│ │ │ └── ObjectPropertyGetPropertyByControlTest.kt
│ │ └── models
│ │ ├── schema
│ │ └── PropertyPatternSerializationTest.kt
│ │ └── uischema
│ │ └── UiSchemaTest.kt
├── README.md
└── build.gradle.kts
├── scripts
└── build_docs.sh
├── settings.gradle.kts
├── renderers
├── cupertino
│ ├── src
│ │ └── commonMain
│ │ │ └── kotlin
│ │ │ └── com
│ │ │ └── paligot
│ │ │ └── jsonforms
│ │ │ └── cupertino
│ │ │ ├── layout
│ │ │ ├── Column.kt
│ │ │ ├── Row.kt
│ │ │ └── CupertinoSection.kt
│ │ │ └── ui
│ │ │ ├── Checkbox.kt
│ │ │ ├── WheelPicker.kt
│ │ │ ├── Switch.kt
│ │ │ ├── SegmentedControl.kt
│ │ │ └── OutlinedTextField.kt
│ ├── api
│ │ ├── desktop
│ │ │ └── cupertino.api
│ │ └── android
│ │ │ └── cupertino.api
│ ├── README.md
│ └── build.gradle.kts
└── material3
│ ├── src
│ └── commonMain
│ │ └── kotlin
│ │ └── com
│ │ └── paligot
│ │ └── jsonforms
│ │ └── material3
│ │ ├── layout
│ │ ├── Column.kt
│ │ └── Row.kt
│ │ └── ui
│ │ ├── Checkbox.kt
│ │ ├── Switch.kt
│ │ └── OutlinedTextField.kt
│ ├── api
│ ├── android
│ │ └── material3.api
│ └── desktop
│ │ └── material3.api
│ ├── README.md
│ └── build.gradle.kts
├── mkdocs.yml
├── ui
├── README.md
├── src
│ ├── commonMain
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── paligot
│ │ │ └── jsonforms
│ │ │ └── ui
│ │ │ ├── RendererBooleanScope.kt
│ │ │ ├── Layout.kt
│ │ │ ├── RendererNumberScope.kt
│ │ │ ├── RendererLayoutScope.kt
│ │ │ ├── JsonForm.kt
│ │ │ └── Property.kt
│ └── desktopTest
│ │ └── kotlin
│ │ └── com
│ │ └── paligot
│ │ └── jsonforms
│ │ └── ui
│ │ ├── RendererNumberScopeTest.kt
│ │ ├── PropertyTest.kt
│ │ ├── RendererLayoutScopeTest.kt
│ │ └── RendererBooleanScopeTest.kt
└── build.gradle.kts
├── README.md
├── docs
├── index.md
├── state-management.md
├── custom-rendering.md
└── create-renderer.md
└── gradlew.bat
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{kt,kts}]
2 | ktlint_function_naming_ignore_when_annotated_with=Composable
3 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GerardPaligot/jsonforms-kotlin/HEAD/gradle.properties
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GerardPaligot/jsonforms-kotlin/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:recommended"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | .kotlin
4 | .idea
5 | .DS_Store
6 | build
7 | .externalNativeBuild
8 | .cxx
9 | local.properties
10 | xcuserdata
11 | site
--------------------------------------------------------------------------------
/.github/ci-gradle.properties:
--------------------------------------------------------------------------------
1 | org.gradle.daemon=false
2 | org.gradle.parallel=true
3 | org.gradle.workers.max=2
4 |
5 | kotlin.incremental=false
6 | kotlin.compiler.execution.strategy=in-process
7 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/Orientation.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | enum class Orientation {
4 | VERTICALLY,
5 | HORIZONTALLY,
6 | }
7 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/schema/Schema.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.schema
2 |
3 | /**
4 | * Alias of [ObjectProperty] for any schema element.
5 | */
6 | typealias Schema = ObjectProperty
7 |
--------------------------------------------------------------------------------
/scripts/build_docs.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -ex
4 |
5 | # Generate the API reference documentation using Dokka
6 | ./gradlew dokkaHtmlMultiModule
7 | mv ./build/dokka/htmlMultiModule docs/api
8 |
9 | # Build the site locally
10 | mkdocs build
11 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/de.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/fr.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
2 | pluginManagement {
3 | repositories {
4 | google()
5 | gradlePluginPortal()
6 | mavenCentral()
7 | }
8 | }
9 |
10 | dependencyResolutionManagement {
11 | repositories {
12 | google()
13 | mavenCentral()
14 | }
15 | }
16 |
17 | rootProject.name = "jsonforms-kotlin"
18 | include(":composeApp")
19 | include(":ui")
20 | include(":renderers:cupertino")
21 | include(":renderers:material3")
22 | include(":shared")
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/Rule.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | /**
6 | * A rule that may be attached to any UI schema element.
7 | */
8 | @Serializable
9 | data class Rule(
10 | /**
11 | * The effect of the rule.
12 | */
13 | val effect: Effect,
14 | /**
15 | * The condition of the rule that must evaluate to true in order to trigger the effect.
16 | */
17 | val condition: Condition,
18 | )
19 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/panes/address/AddressUiModel.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.panes.address
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.Schema
4 | import com.paligot.jsonforms.kotlin.models.uischema.UiSchema
5 |
6 | data class GeneratedAddressUiModel(
7 | val street: String,
8 | val city: String,
9 | val country: String,
10 | )
11 |
12 | data class AddressUiModel(
13 | val schema: Schema,
14 | val uiSchema: UiSchema,
15 | val generatedAddress: GeneratedAddressUiModel? = null,
16 | )
17 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/LayoutOptions.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | /**
6 | * Options for a layout element.
7 | */
8 | @Serializable
9 | data class LayoutOptions(
10 | /**
11 | * The vertical spacing between elements.
12 | */
13 | val verticalSpacing: String? = null,
14 | /**
15 | * The horizontal spacing between elements.
16 | */
17 | val horizontalSpacing: String? = null,
18 | override val weight: Float? = null,
19 | ) : Options
20 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/Condition.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.Property
4 | import kotlinx.serialization.Serializable
5 |
6 | /**
7 | * Represents a condition to be evaluated.
8 | */
9 | @Serializable
10 | data class Condition(
11 | /**
12 | * The scope that determines to which part this element should be bound to.
13 | */
14 | val scope: String,
15 | /**
16 | * Schema evaluated to apply or not the condition.
17 | */
18 | val schema: Property,
19 | )
20 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/Effect.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | /**
4 | * The different rule effects.
5 | */
6 | enum class Effect {
7 | /**
8 | * Effect that hides the associated element.
9 | */
10 | Hide,
11 |
12 | /**
13 | * Effect that shows the associated element.
14 | */
15 | Show,
16 |
17 | /**
18 | * Effect that disables the associated element.
19 | */
20 | Disable,
21 |
22 | /**
23 | * Effect that enables the associated element.
24 | */
25 | Enable,
26 | }
27 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/com/paligot/jsonforms/kotlin/android/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.android
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import com.paligot.jsonforms.kotlin.App
7 | import com.paligot.jsonforms.kotlin.ui.MyApplicationTheme
8 |
9 | class MainActivity : ComponentActivity() {
10 | override fun onCreate(savedInstanceState: Bundle?) {
11 | super.onCreate(savedInstanceState)
12 | setContent {
13 | MyApplicationTheme {
14 | App()
15 | }
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/com/paligot/jsonforms/kotlin/desktop/main.kt:
--------------------------------------------------------------------------------
1 |
2 | @file:Suppress("ktlint:standard:filename")
3 |
4 | package com.paligot.jsonforms.kotlin.desktop
5 |
6 | import androidx.compose.ui.window.Window
7 | import androidx.compose.ui.window.application
8 | import com.paligot.jsonforms.kotlin.App
9 | import com.paligot.jsonforms.kotlin.ui.MyApplicationTheme
10 |
11 | fun main() =
12 | application {
13 | Window(
14 | onCloseRequest = ::exitApplication,
15 | title = "Jsonform Sample",
16 | ) {
17 | MyApplicationTheme {
18 | App()
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/App.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin
2 |
3 | import androidx.compose.animation.EnterTransition
4 | import androidx.compose.animation.ExitTransition
5 | import androidx.compose.runtime.Composable
6 | import androidx.navigation.compose.NavHost
7 | import androidx.navigation.compose.rememberNavController
8 |
9 | @Composable
10 | fun App() {
11 | val navController = rememberNavController()
12 | NavHost(
13 | navController = navController,
14 | startDestination = FormList::class,
15 | enterTransition = { EnterTransition.None },
16 | exitTransition = { ExitTransition.None },
17 | builder = {
18 | formDescriptionNavGraph(navController)
19 | },
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/checks/BooleanProperty.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.checks
2 |
3 | import com.paligot.jsonforms.kotlin.internal.FieldError
4 | import com.paligot.jsonforms.kotlin.models.schema.BooleanProperty
5 |
6 | /**
7 | * Validates the given boolean value against the constraints defined in the `BooleanProperty`.
8 | *
9 | * @param scopeKey The scope key of the property being validated.
10 | * @param value The boolean value to validate.
11 | * @return A list of [FieldError] objects representing validation errors, or an empty list if the value is valid.
12 | */
13 | internal fun BooleanProperty.validate(
14 | scopeKey: String,
15 | value: Boolean,
16 | ): List = validateProperty(scopeKey, value)
17 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/checks/ValidationCheck.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.checks
2 |
3 | import com.paligot.jsonforms.kotlin.internal.FieldError
4 | import com.paligot.jsonforms.kotlin.internal.ext.propertyKey
5 | import com.paligot.jsonforms.kotlin.internal.queries.findVisibleControls
6 | import com.paligot.jsonforms.kotlin.models.schema.Schema
7 | import com.paligot.jsonforms.kotlin.models.uischema.UiSchema
8 |
9 | class ValidationCheck(
10 | private val schema: Schema,
11 | private val uiSchema: UiSchema,
12 | ) {
13 | fun check(data: Map): List {
14 | val controls = uiSchema.findVisibleControls(data)
15 | return schema.validate(data, controls.map { it.propertyKey() })
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/panes/address/AddressFormVM.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.panes.address
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.collectAsState
5 | import androidx.compose.ui.Modifier
6 | import androidx.lifecycle.viewmodel.compose.viewModel
7 |
8 | @Composable
9 | fun AddressFormVM(
10 | onBackClick: () -> Unit,
11 | modifier: Modifier = Modifier,
12 | viewModel: AddressFormViewModel = viewModel { AddressFormViewModel() },
13 | ) {
14 | val uiState = viewModel.uiState.collectAsState()
15 | AddressFormPane(
16 | uiModel = uiState.value,
17 | modifier = modifier,
18 | onStreetChange = viewModel::queryChange,
19 | onBackClick = onBackClick,
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/renderers/cupertino/src/commonMain/kotlin/com/paligot/jsonforms/cupertino/layout/Column.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.cupertino.layout
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.ColumnScope
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.unit.dp
8 |
9 | @Composable
10 | internal fun Column(
11 | verticalSpacing: String?,
12 | modifier: Modifier = Modifier,
13 | content: @Composable ColumnScope.() -> Unit,
14 | ) {
15 | androidx.compose.foundation.layout.Column(
16 | modifier = modifier,
17 | verticalArrangement =
18 | verticalSpacing
19 | ?.let { Arrangement.spacedBy(it.toInt().dp) }
20 | ?: run { Arrangement.Top },
21 | content = content,
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/renderers/material3/src/commonMain/kotlin/com/paligot/jsonforms/material3/layout/Column.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.material3.layout
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.ColumnScope
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.unit.dp
8 |
9 | @Composable
10 | internal fun Column(
11 | verticalSpacing: String?,
12 | modifier: Modifier = Modifier,
13 | content: @Composable ColumnScope.() -> Unit,
14 | ) {
15 | androidx.compose.foundation.layout.Column(
16 | modifier = modifier,
17 | verticalArrangement =
18 | verticalSpacing
19 | ?.let { Arrangement.spacedBy(it.toInt().dp) }
20 | ?: run { Arrangement.Top },
21 | content = content,
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/HorizontalLayout.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.ImmutableListSerializer
4 | import kotlinx.collections.immutable.ImmutableList
5 | import kotlinx.collections.immutable.persistentListOf
6 | import kotlinx.serialization.SerialName
7 | import kotlinx.serialization.Serializable
8 |
9 | /**
10 | * A layout which orders its children horizontally (i.e. from left to right).
11 | */
12 | @Serializable
13 | @SerialName("HorizontalLayout")
14 | data class HorizontalLayout(
15 | @Serializable(with = ImmutableListSerializer::class)
16 | override val elements: ImmutableList = persistentListOf(),
17 | override val rule: Rule? = null,
18 | override val options: LayoutOptions? = null,
19 | ) : UiSchema()
20 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/VerticalLayout.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.ImmutableListSerializer
4 | import kotlinx.collections.immutable.ImmutableList
5 | import kotlinx.collections.immutable.persistentListOf
6 | import kotlinx.serialization.SerialName
7 | import kotlinx.serialization.Serializable
8 |
9 | /**
10 | * A layout which orders its child elements vertically (i.e. from top to bottom).
11 | */
12 | @Serializable
13 | @SerialName("VerticalLayout")
14 | data class VerticalLayout(
15 | @Serializable(with = ImmutableListSerializer::class)
16 | override val elements: ImmutableList = persistentListOf(),
17 | override val rule: Rule? = null,
18 | override val options: LayoutOptions? = null,
19 | ) : UiSchema()
20 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/serializers/RegexSerializer.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.serializers
2 |
3 | import kotlinx.serialization.KSerializer
4 | import kotlinx.serialization.builtins.serializer
5 | import kotlinx.serialization.descriptors.SerialDescriptor
6 | import kotlinx.serialization.encoding.Decoder
7 | import kotlinx.serialization.encoding.Encoder
8 |
9 | class RegexSerializer : KSerializer {
10 | private val delegateSerializer = String.serializer()
11 |
12 | override val descriptor: SerialDescriptor = delegateSerializer.descriptor
13 |
14 | override fun serialize(
15 | encoder: Encoder,
16 | value: Regex,
17 | ) {
18 | delegateSerializer.serialize(encoder, value.pattern)
19 | }
20 |
21 | override fun deserialize(decoder: Decoder): Regex = delegateSerializer.deserialize(decoder).toRegex()
22 | }
23 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/schema/BooleanProperty.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.schema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.RegexSerializer
4 | import kotlinx.serialization.SerialName
5 | import kotlinx.serialization.Serializable
6 | import kotlinx.serialization.json.JsonPrimitive
7 |
8 | /**
9 | * A property which configure a field with a boolean value.
10 | */
11 | @Serializable
12 | @SerialName("boolean")
13 | data class BooleanProperty(
14 | override val title: String? = null,
15 | override val format: String? = null,
16 | override val description: String? = null,
17 | override val readOnly: Boolean? = null,
18 | override val const: JsonPrimitive? = null,
19 | override val not: Property? = null,
20 | @Serializable(with = RegexSerializer::class)
21 | override val pattern: Regex? = null,
22 | ) : Property()
23 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/UiSchema.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.ImmutableListSerializer
4 | import kotlinx.collections.immutable.ImmutableList
5 | import kotlinx.serialization.Serializable
6 |
7 | /**
8 | * Common base sealed class for any UI schema element.
9 | */
10 | @Serializable
11 | sealed class UiSchema {
12 | /**
13 | * The child elements of this layout.
14 | */
15 | @Serializable(with = ImmutableListSerializer::class)
16 | abstract val elements: ImmutableList?
17 |
18 | /**
19 | * The optional options applied of the element.
20 | */
21 | abstract val options: Options?
22 |
23 | /**
24 | * An optional rule.
25 | */
26 | abstract val rule: Rule?
27 | }
28 |
29 | interface Options {
30 | val weight: Float?
31 | }
32 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/Format.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | /**
4 | * The different formats.
5 | */
6 | enum class Format {
7 | /**
8 | * Format that display a radio to the associated element.
9 | */
10 | Radio,
11 |
12 | /**
13 | * Format that display a toggle to the associated element.
14 | */
15 | Toggle,
16 |
17 | /**
18 | * Format that display a text input in password mode to the associated element.
19 | */
20 | Password,
21 |
22 | /**
23 | * Format that display a text input in email mode to the associated element.
24 | */
25 | Email,
26 |
27 | /**
28 | * Format that display a text input in phone mode to the associated element.
29 | */
30 | Phone,
31 |
32 | /**
33 | * Format that display a date picker to the associated element.
34 | */
35 | Date,
36 | }
37 |
--------------------------------------------------------------------------------
/renderers/cupertino/src/commonMain/kotlin/com/paligot/jsonforms/cupertino/layout/Row.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.cupertino.layout
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.RowScope
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Alignment
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.unit.dp
9 |
10 | @Composable
11 | internal fun Row(
12 | horizontalSpacing: String?,
13 | modifier: Modifier = Modifier,
14 | content: @Composable RowScope.() -> Unit,
15 | ) {
16 | androidx.compose.foundation.layout.Row(
17 | modifier = modifier,
18 | horizontalArrangement =
19 | horizontalSpacing
20 | ?.let { Arrangement.spacedBy(it.toInt().dp) }
21 | ?: run { Arrangement.Start },
22 | verticalAlignment = Alignment.CenterVertically,
23 | content = content,
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/renderers/material3/src/commonMain/kotlin/com/paligot/jsonforms/material3/layout/Row.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.material3.layout
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.RowScope
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Alignment
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.unit.dp
9 |
10 | @Composable
11 | internal fun Row(
12 | horizontalSpacing: String?,
13 | modifier: Modifier = Modifier,
14 | content: @Composable RowScope.() -> Unit,
15 | ) {
16 | androidx.compose.foundation.layout.Row(
17 | modifier = modifier,
18 | horizontalArrangement =
19 | horizontalSpacing
20 | ?.let { Arrangement.spacedBy(it.toInt().dp) }
21 | ?: run { Arrangement.Start },
22 | verticalAlignment = Alignment.CenterVertically,
23 | content = content,
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/ext/BooleanProperty.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.BooleanProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Control
5 | import com.paligot.jsonforms.kotlin.models.uischema.Format
6 |
7 | /**
8 | * Check if [BooleanProperty] is a toggle based on the format.
9 | *
10 | * ```kotlin
11 | * val booleanProperty = BooleanProperty()
12 | * val control = Control(
13 | * scope = "#/properties/boolean",
14 | * options = Options(format = Format.Toggle)
15 | * )
16 | * val isToggle = booleanProperty.isToggle(control)
17 | * ```
18 | *
19 | * @param control Field contained in the [com.paligot.jsonforms.kotlin.models.uischema.UiSchema]
20 | * @return true if the [BooleanProperty] is a toggle
21 | */
22 | fun BooleanProperty.isToggle(control: Control): Boolean = control.options?.format == Format.Toggle
23 |
--------------------------------------------------------------------------------
/renderers/material3/api/android/material3.api:
--------------------------------------------------------------------------------
1 | public final class com/paligot/jsonforms/material3/MaterialRendererKt {
2 | public static final fun Material3BooleanProperty (Lcom/paligot/jsonforms/ui/RendererBooleanScope;ZLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
3 | public static final fun Material3Layout (Lcom/paligot/jsonforms/ui/RendererLayoutScope;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
4 | public static final fun Material3NumberProperty (Lcom/paligot/jsonforms/ui/RendererNumberScope;Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
5 | public static final fun Material3StringProperty (Lcom/paligot/jsonforms/ui/RendererStringScope;Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
6 | }
7 |
8 |
--------------------------------------------------------------------------------
/renderers/material3/api/desktop/material3.api:
--------------------------------------------------------------------------------
1 | public final class com/paligot/jsonforms/material3/MaterialRendererKt {
2 | public static final fun Material3BooleanProperty (Lcom/paligot/jsonforms/ui/RendererBooleanScope;ZLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
3 | public static final fun Material3Layout (Lcom/paligot/jsonforms/ui/RendererLayoutScope;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
4 | public static final fun Material3NumberProperty (Lcom/paligot/jsonforms/ui/RendererNumberScope;Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
5 | public static final fun Material3StringProperty (Lcom/paligot/jsonforms/ui/RendererStringScope;Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
6 | }
7 |
8 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/checks/BooleanPropertyValidateTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.checks
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.BooleanProperty
4 | import kotlin.test.Test
5 | import kotlin.test.assertEquals
6 |
7 | class BooleanPropertyValidateTest {
8 | @Test
9 | fun `validate should return no errors for a valid true boolean value`() {
10 | val property = BooleanProperty()
11 | val scopeKey = "key"
12 | val value = true
13 |
14 | val result = property.validate(scopeKey, value)
15 |
16 | assertEquals(emptyList(), result)
17 | }
18 |
19 | @Test
20 | fun `validate should return no errors for an valid false boolean value`() {
21 | val property = BooleanProperty()
22 | val scopeKey = "key"
23 | val value = false
24 |
25 | val result = property.validate(scopeKey, value)
26 |
27 | assertEquals(emptyList(), result)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: "jsonforms-kotlin"
2 | site_description: "Kotlin Multiplatform implementation of the JSONForms standard from the Eclipse Foundation"
3 | site_author: "Gérard Paligot"
4 | edit_uri: "tree/main/docs/"
5 | remote_branch: gh-pages
6 | strict: true
7 |
8 | docs_dir: docs
9 |
10 | repo_name: "jsonforms-kotlin"
11 | repo_url: "https://github.com/GerardPaligot/jsonforms-kotlin"
12 |
13 | nav:
14 | - "Overview": index.md
15 | - "Usage": usage.md
16 | - "State Management": state-management.md
17 | - "Custom Rendering": custom-rendering.md
18 | - "Create Renderer": create-renderer.md
19 | - "API reference": api/index.html
20 |
21 | theme:
22 | name: "material"
23 | language: "en"
24 | palette:
25 | primary: "white"
26 | accent: "teal"
27 | font:
28 | text: "DM Sans"
29 | code: "DM Mono"
30 | features:
31 | - content.tabs.link
32 | - content.code.copy
33 |
34 | plugins:
35 | - search
36 |
37 | validation:
38 | unrecognized_links: warn
39 | anchors: warn
40 |
--------------------------------------------------------------------------------
/ui/README.md:
--------------------------------------------------------------------------------
1 | # UI
2 |
3 | This module provides the core UI logic and composable components. It is responsible for form state
4 | management, the main `JsonForm` component, and the interfaces and scopes that enable custom and
5 | platform-specific renderers.
6 |
7 | ## Usage
8 |
9 | Add the dependency to your project:
10 |
11 | ```kotlin
12 | dependencies {
13 | implementation("com.paligot.jsonforms.kotlin:ui:")
14 | }
15 | ```
16 |
17 | Import and use the main UI components in your code:
18 |
19 | ```kotlin
20 | import com.paligot.jsonforms.ui.*
21 |
22 | val formState = rememberJsonFormState(mutableMapOf("email" to "", "password" to ""))
23 |
24 | JsonForm(
25 | schema = schema,
26 | uiSchema = uiSchema,
27 | state = formState,
28 | layoutContent = { /* your layout renderer */ },
29 | stringContent = { /* your string field renderer */ },
30 | numberContent = { /* your number field renderer */ },
31 | booleanContent = { /* your boolean field renderer */ }
32 | )
33 | ```
34 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/Control.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.ImmutableListSerializer
4 | import kotlinx.collections.immutable.ImmutableList
5 | import kotlinx.serialization.SerialName
6 | import kotlinx.serialization.Serializable
7 |
8 | /**
9 | * A control element which represent a UI element in the form.
10 | */
11 | @Serializable
12 | @SerialName("Control")
13 | data class Control(
14 | /**
15 | * The scope that determines to which part this element should be bound to.
16 | */
17 | val scope: String,
18 | /**
19 | * Label for UI schema element.
20 | */
21 | var label: String? = null,
22 | /**
23 | * Any additional options.
24 | */
25 | override val options: ControlOptions? = null,
26 | @Serializable(with = ImmutableListSerializer::class)
27 | override val elements: ImmutableList? = null,
28 | override val rule: Rule? = null,
29 | ) : UiSchema()
30 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/GroupLayout.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.ImmutableListSerializer
4 | import kotlinx.collections.immutable.ImmutableList
5 | import kotlinx.collections.immutable.persistentListOf
6 | import kotlinx.serialization.SerialName
7 | import kotlinx.serialization.Serializable
8 |
9 | /**
10 | * A group resembles a vertical layout, but additionally might have a label.
11 | * This layout is useful when grouping different elements by a certain criteria.
12 | */
13 | @Serializable
14 | @SerialName("Group")
15 | data class GroupLayout(
16 | /**
17 | * Label for UI schema element
18 | */
19 | val label: String,
20 | val description: String? = null,
21 | @Serializable(with = ImmutableListSerializer::class)
22 | override val elements: ImmutableList = persistentListOf(),
23 | override val rule: Rule? = null,
24 | override val options: LayoutOptions? = null,
25 | ) : UiSchema()
26 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/serializers/ImmutableListSerializer.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.serializers
2 |
3 | import kotlinx.collections.immutable.ImmutableList
4 | import kotlinx.collections.immutable.toImmutableList
5 | import kotlinx.serialization.KSerializer
6 | import kotlinx.serialization.builtins.ListSerializer
7 | import kotlinx.serialization.encoding.Decoder
8 | import kotlinx.serialization.encoding.Encoder
9 |
10 | class ImmutableListSerializer(elementSerializer: KSerializer) : KSerializer> {
11 | private val delegateSerializer = ListSerializer(elementSerializer)
12 |
13 | override val descriptor = delegateSerializer.descriptor
14 |
15 | override fun serialize(
16 | encoder: Encoder,
17 | value: ImmutableList,
18 | ) {
19 | delegateSerializer.serialize(encoder, value.toList())
20 | }
21 |
22 | override fun deserialize(decoder: Decoder): ImmutableList {
23 | return delegateSerializer.deserialize(decoder).toImmutableList()
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/panes/address/geocode/GeocodeNet.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.panes.address.geocode
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class AddressComponent(
8 | @SerialName("long_name")
9 | val longName: String,
10 | @SerialName("short_name")
11 | val shortName: String,
12 | val types: List,
13 | )
14 |
15 | @Serializable
16 | data class Geometry(
17 | val location: Location,
18 | )
19 |
20 | @Serializable
21 | data class Location(
22 | val lat: Double,
23 | val lng: Double,
24 | )
25 |
26 | @Serializable
27 | data class Result(
28 | @SerialName("address_components")
29 | val addressComponents: List,
30 | @SerialName("formatted_address")
31 | val formattedAddress: String,
32 | val geometry: Geometry,
33 | @SerialName("place_id")
34 | val placeId: String,
35 | val types: List,
36 | )
37 |
38 | @Serializable
39 | data class Geocode(
40 | val results: List,
41 | val status: String,
42 | )
43 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/serializers/ImmutableMapSerializer.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.serializers
2 |
3 | import kotlinx.collections.immutable.ImmutableMap
4 | import kotlinx.collections.immutable.toImmutableMap
5 | import kotlinx.serialization.KSerializer
6 | import kotlinx.serialization.builtins.MapSerializer
7 | import kotlinx.serialization.encoding.Decoder
8 | import kotlinx.serialization.encoding.Encoder
9 |
10 | class ImmutableMapSerializer(
11 | keySerializer: KSerializer,
12 | valueSerializer: KSerializer,
13 | ) : KSerializer> {
14 | private val delegateSerializer = MapSerializer(keySerializer, valueSerializer)
15 |
16 | override val descriptor = delegateSerializer.descriptor
17 |
18 | override fun serialize(
19 | encoder: Encoder,
20 | value: ImmutableMap,
21 | ) {
22 | delegateSerializer.serialize(encoder, value.toMap())
23 | }
24 |
25 | override fun deserialize(decoder: Decoder): ImmutableMap {
26 | return delegateSerializer.deserialize(decoder).toImmutableMap()
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/shared/README.md:
--------------------------------------------------------------------------------
1 | # Shared
2 |
3 | This module contains the core models, schema definitions, and utilities. It is designed to be used
4 | across all platforms and renderer modules, providing the foundation for dynamic form generation and
5 | validation.
6 |
7 | ## Usage
8 |
9 | Add the dependency to your project:
10 |
11 | ```kotlin
12 | dependencies {
13 | implementation("com.paligot.jsonforms.kotlin:shared:")
14 | }
15 | ```
16 |
17 | Import and use the schema and UI schema models in your code:
18 |
19 | ```kotlin
20 | import com.paligot.jsonforms.kotlin.models.schema.*
21 | import com.paligot.jsonforms.kotlin.models.uischema.*
22 |
23 | val schema = Schema(
24 | properties = persistentMapOf(
25 | "email" to StringProperty(pattern = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"),
26 | "password" to StringProperty()
27 | ),
28 | required = persistentListOf("email", "password")
29 | )
30 |
31 | val uiSchema = VerticalLayout(
32 | elements = persistentListOf(
33 | Control(scope = "#/properties/email", label = "Email"),
34 | Control(scope = "#/properties/password", label = "Password")
35 | )
36 | )
37 | ```
38 |
--------------------------------------------------------------------------------
/renderers/cupertino/src/commonMain/kotlin/com/paligot/jsonforms/cupertino/layout/CupertinoSection.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.cupertino.layout
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.Modifier
5 | import com.slapps.cupertino.CupertinoText
6 | import com.slapps.cupertino.ExperimentalCupertinoApi
7 | import com.slapps.cupertino.section.CupertinoSection
8 | import com.slapps.cupertino.section.SectionScope
9 |
10 | @OptIn(ExperimentalCupertinoApi::class)
11 | @Composable
12 | internal fun CupertinoSection(
13 | title: String?,
14 | description: String?,
15 | modifier: Modifier = Modifier,
16 | content: @Composable SectionScope.() -> Unit,
17 | ) {
18 | CupertinoSection(
19 | title =
20 | if (title != null) {
21 | { CupertinoText(text = title) }
22 | } else {
23 | null
24 | },
25 | caption =
26 | if (description != null) {
27 | { CupertinoText(text = description) }
28 | } else {
29 | null
30 | },
31 | modifier = modifier,
32 | content = content,
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/schema/NumberProperty.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.schema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.RegexSerializer
4 | import kotlinx.serialization.SerialName
5 | import kotlinx.serialization.Serializable
6 | import kotlinx.serialization.json.JsonPrimitive
7 |
8 | /**
9 | * A property which configure a field with a number value.
10 | */
11 | @Serializable
12 | @SerialName("number")
13 | data class NumberProperty(
14 | override val title: String? = null,
15 | override val format: String? = null,
16 | override val description: String? = null,
17 | override val readOnly: Boolean? = null,
18 | override val const: JsonPrimitive? = null,
19 | override val not: Property? = null,
20 | @Serializable(with = RegexSerializer::class)
21 | override val pattern: Regex? = null,
22 | /**
23 | * An optional maximum to validate the number value.
24 | */
25 | val maximum: Int? = null,
26 | /**
27 | * An optional minimum to validate the number value.
28 | */
29 | val minimum: Int? = null,
30 | /**
31 | * An optional default value.
32 | */
33 | val default: Int? = null,
34 | ) : Property()
35 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/uischema/ControlOptions.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | /**
6 | * An option for a control element.
7 | */
8 | @Serializable
9 | data class ControlOptions(
10 | /**
11 | * An optional format.
12 | */
13 | val format: Format? = null,
14 | /**
15 | * An optional orientation.
16 | */
17 | val orientation: Orientation? = null,
18 | /**
19 | * The vertical spacing between elements.
20 | */
21 | val verticalSpacing: String? = null,
22 | /**
23 | * The horizontal spacing between elements.
24 | */
25 | val horizontalSpacing: String? = null,
26 | /**
27 | * The boolean to know if the control element will be interactive.
28 | */
29 | val readOnly: Boolean = false,
30 | /**
31 | * The boolean to know if the control element will have a max counter.
32 | */
33 | val showMaxCounter: Boolean = false,
34 | /**
35 | * The boolean to know if the control element will have the first letter capitalized.
36 | */
37 | val hasFirstLetterCapitalized: Boolean = false,
38 | override val weight: Float? = null,
39 | ) : Options
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://opensource.org/licenses/Apache-2.0)
2 |
3 | ## jsonforms-kotlin
4 |
5 | `jsonforms-kotlin` is a Kotlin Multiplatform implementation of the [JSONForms](https://jsonforms.io/)
6 | standard from the Eclipse Foundation. It leverages the power of [Compose Multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/)
7 | to render dynamic forms based on JSON Schemas and UI Schemas across various platforms,
8 | including Android, iOS, and the JVM.
9 |
10 | Check out the website for more information: https://gerard.paligot.com/jsonforms-kotlin/
11 |
12 | ## License
13 |
14 | ```
15 | Copyright 2025 Gérard Paligot.
16 |
17 | Licensed under the Apache License, Version 2.0 (the "License");
18 | you may not use this file except in compliance with the License.
19 | You may obtain a copy of the License at
20 |
21 | http://www.apache.org/licenses/LICENSE-2.0
22 |
23 | Unless required by applicable law or agreed to in writing, software
24 | distributed under the License is distributed on an "AS IS" BASIS,
25 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
26 | See the License for the specific language governing permissions and
27 | limitations under the License.
28 | ```
29 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/schema/ArrayProperty.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.schema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.ObjectPropertyListSerializer
4 | import com.paligot.jsonforms.kotlin.models.serializers.RegexSerializer
5 | import kotlinx.serialization.SerialName
6 | import kotlinx.serialization.Serializable
7 | import kotlinx.serialization.json.JsonPrimitive
8 |
9 | /**
10 | * A property which configure a field with an array value.
11 | */
12 | @Serializable
13 | @SerialName("array")
14 | data class ArrayProperty(
15 | override val title: String? = null,
16 | override val format: String? = null,
17 | override val description: String? = null,
18 | override val readOnly: Boolean? = null,
19 | override val const: JsonPrimitive? = null,
20 | override val not: Property? = null,
21 | @Serializable(with = RegexSerializer::class)
22 | override val pattern: Regex? = null,
23 | val items: Property? = null,
24 | @Serializable(with = ObjectPropertyListSerializer::class)
25 | val prefixItems: List? = null,
26 | val uniqueItems: Boolean = false,
27 | @Serializable(with = ObjectPropertyListSerializer::class)
28 | val contains: List? = null,
29 | ) : Property()
30 |
--------------------------------------------------------------------------------
/renderers/cupertino/api/desktop/cupertino.api:
--------------------------------------------------------------------------------
1 | public final class com/paligot/jsonforms/cupertino/CupertinoRendererKt {
2 | public static final fun CupertinoBooleanProperty (Lcom/paligot/jsonforms/ui/RendererBooleanScope;ZLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
3 | public static final fun CupertinoLayout (Lcom/paligot/jsonforms/ui/RendererLayoutScope;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
4 | public static final fun CupertinoNumberProperty (Lcom/paligot/jsonforms/ui/RendererNumberScope;Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
5 | public static final fun CupertinoStringProperty (Lcom/paligot/jsonforms/ui/RendererStringScope;Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
6 | }
7 |
8 | public final class com/paligot/jsonforms/cupertino/ui/ComposableSingletons$WheelPickerKt {
9 | public static final field INSTANCE Lcom/paligot/jsonforms/cupertino/ui/ComposableSingletons$WheelPickerKt;
10 | public fun ()V
11 | public final fun getLambda$298379313$cupertino ()Lkotlin/jvm/functions/Function3;
12 | }
13 |
14 |
--------------------------------------------------------------------------------
/renderers/cupertino/api/android/cupertino.api:
--------------------------------------------------------------------------------
1 | public final class com/paligot/jsonforms/cupertino/CupertinoRendererKt {
2 | public static final fun CupertinoBooleanProperty (Lcom/paligot/jsonforms/ui/RendererBooleanScope;ZLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
3 | public static final fun CupertinoLayout (Lcom/paligot/jsonforms/ui/RendererLayoutScope;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
4 | public static final fun CupertinoNumberProperty (Lcom/paligot/jsonforms/ui/RendererNumberScope;Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
5 | public static final fun CupertinoStringProperty (Lcom/paligot/jsonforms/ui/RendererStringScope;Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
6 | }
7 |
8 | public final class com/paligot/jsonforms/cupertino/ui/ComposableSingletons$WheelPickerKt {
9 | public static final field INSTANCE Lcom/paligot/jsonforms/cupertino/ui/ComposableSingletons$WheelPickerKt;
10 | public fun ()V
11 | public final fun getLambda$298379313$cupertino_release ()Lkotlin/jvm/functions/Function3;
12 | }
13 |
14 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/BooleanPropertyIsToggleTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.BooleanProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Control
5 | import com.paligot.jsonforms.kotlin.models.uischema.ControlOptions
6 | import com.paligot.jsonforms.kotlin.models.uischema.Format
7 | import kotlin.test.Test
8 | import kotlin.test.assertFalse
9 | import kotlin.test.assertTrue
10 |
11 | class BooleanPropertyIsToggleTest {
12 | @Test
13 | fun `isToggle should return true when control format is Toggle`() {
14 | val property = BooleanProperty()
15 | val control =
16 | Control(
17 | scope = "#/properties/boolean",
18 | options = ControlOptions(format = Format.Toggle),
19 | )
20 |
21 | val result = property.isToggle(control)
22 |
23 | assertTrue(result)
24 | }
25 |
26 | @Test
27 | fun `isToggle should return false when control options are null`() {
28 | val property = BooleanProperty()
29 | val control = Control(scope = "#/properties/boolean", options = null)
30 |
31 | val result = property.isToggle(control)
32 |
33 | assertFalse(result)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/schema/Property.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.schema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.RegexSerializer
4 | import kotlinx.serialization.Serializable
5 | import kotlinx.serialization.json.JsonPrimitive
6 |
7 | /**
8 | * Common base sealed class for any property element.
9 | */
10 | @Serializable
11 | sealed class Property {
12 | /**
13 | * An optional title.
14 | */
15 | abstract val title: String?
16 |
17 | /**
18 | * An optional format.
19 | */
20 | abstract val format: String?
21 |
22 | /**
23 | * An optional description.
24 | */
25 | abstract val description: String?
26 |
27 | /**
28 | * An optional boolean to know if it is interactive.
29 | */
30 | abstract val readOnly: Boolean?
31 |
32 | /**
33 | * An optional const value to restrict the value of the property.
34 | */
35 | abstract val const: JsonPrimitive?
36 |
37 | /**
38 | * An optional not value to restrict the value of the property.
39 | */
40 | abstract val not: Property?
41 |
42 | /**
43 | * An optional pattern to restrict the value of the property.
44 | */
45 | @Serializable(with = RegexSerializer::class)
46 | abstract val pattern: Regex?
47 | }
48 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/checks/NumberProperty.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.checks
2 |
3 | import com.paligot.jsonforms.kotlin.internal.FieldError
4 | import com.paligot.jsonforms.kotlin.models.schema.NumberProperty
5 |
6 | /**
7 | * Validates a numeric property against its constraints such as minimum, maximum, and format.
8 | *
9 | * @param scopeKey The scope key of the property being validated.
10 | * @param value The string representation of the numeric value to validate.
11 | * @return A list of [FieldError] containing validation errors, if any.
12 | */
13 | internal fun NumberProperty.validate(
14 | scopeKey: String,
15 | value: String,
16 | ): List {
17 | val errors = mutableListOf()
18 | val floatValue =
19 | try {
20 | value.replace(",", ".").toFloat()
21 | } catch (exception: NumberFormatException) {
22 | errors.add(FieldError.MalformedFieldError(scopeKey))
23 | return errors
24 | }
25 | if (minimum != null && floatValue < minimum) {
26 | errors.add(FieldError.MinValueFieldError(minimum, scopeKey))
27 | }
28 | if (maximum != null && floatValue > maximum) {
29 | errors.add(FieldError.MaxValueFieldError(maximum, scopeKey))
30 | }
31 | errors.addAll(validateProperty(scopeKey, value))
32 | return errors
33 | }
34 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/StringPropertyIsDropdownTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
4 | import kotlinx.collections.immutable.persistentListOf
5 | import kotlinx.serialization.json.JsonPrimitive
6 | import kotlin.test.Test
7 | import kotlin.test.assertFalse
8 | import kotlin.test.assertTrue
9 |
10 | class StringPropertyIsDropdownTest {
11 | @Test
12 | fun `isDropdown should return true when oneOf is not null and not empty`() {
13 | val property =
14 | StringProperty(
15 | oneOf =
16 | persistentListOf(
17 | StringProperty(const = JsonPrimitive("value1"), title = "Title1"),
18 | ),
19 | )
20 |
21 | val result = property.isDropdown()
22 |
23 | assertTrue(result)
24 | }
25 |
26 | @Test
27 | fun `isDropdown should return false when oneOf is null`() {
28 | val property = StringProperty(oneOf = null)
29 |
30 | val result = property.isDropdown()
31 |
32 | assertFalse(result)
33 | }
34 |
35 | @Test
36 | fun `isDropdown should return false when oneOf is empty`() {
37 | val property = StringProperty(oneOf = persistentListOf())
38 |
39 | val result = property.isDropdown()
40 |
41 | assertFalse(result)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/renderers/material3/src/commonMain/kotlin/com/paligot/jsonforms/material3/ui/Checkbox.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.material3.ui
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.material3.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.unit.dp
11 |
12 | @Composable
13 | internal fun Checkbox(
14 | value: Boolean,
15 | modifier: Modifier = Modifier,
16 | label: String? = null,
17 | enabled: Boolean = true,
18 | onCheckedChange: ((Boolean) -> Unit),
19 | ) {
20 | Row(
21 | modifier =
22 | if (label != null) {
23 | modifier.clickable { onCheckedChange(!value) }
24 | } else {
25 | modifier
26 | },
27 | verticalAlignment = Alignment.CenterVertically,
28 | horizontalArrangement = Arrangement.spacedBy(4.dp),
29 | ) {
30 | androidx.compose.material3.Checkbox(
31 | checked = value,
32 | onCheckedChange =
33 | if (label == null) {
34 | { onCheckedChange(it) }
35 | } else {
36 | null
37 | },
38 | enabled = enabled,
39 | )
40 | if (label != null) {
41 | Text(text = label)
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yaml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | publish:
10 | runs-on: macos-14
11 | timeout-minutes: 60
12 | steps:
13 | - uses: actions/checkout@v4
14 |
15 | - uses: actions/setup-java@v4
16 | with:
17 | distribution: temurin
18 | java-version: 21
19 |
20 | - uses: gradle/actions/setup-gradle@v4
21 | with:
22 | gradle-version: wrapper
23 | build-scan-publish: true
24 | build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
25 | build-scan-terms-of-use-agree: "yes"
26 |
27 | - name: Copy CI gradle.properties
28 | run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
29 |
30 | - name: Publishing to Maven Central
31 | # FIXME --no-configuration-cache flag https://github.com/vanniktech/gradle-maven-publish-plugin/issues/259
32 | run: ./gradlew publishAllPublicationsToMavenCentralRepository --no-configuration-cache
33 | env:
34 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME }}
35 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD }}
36 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEY }}
37 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEYPASSWORD }}
38 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/queries/UiSchema.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.queries
2 |
3 | import com.paligot.jsonforms.kotlin.internal.ext.evaluateShow
4 | import com.paligot.jsonforms.kotlin.models.uischema.Control
5 | import com.paligot.jsonforms.kotlin.models.uischema.GroupLayout
6 | import com.paligot.jsonforms.kotlin.models.uischema.HorizontalLayout
7 | import com.paligot.jsonforms.kotlin.models.uischema.UiSchema
8 | import com.paligot.jsonforms.kotlin.models.uischema.VerticalLayout
9 |
10 | /**
11 | * Finds all visible controls in the current [UiSchema] based on the provided data.
12 | *
13 | * This function traverses the [UiSchema] structure and evaluates visibility rules
14 | * for each element. If an element is visible, its associated control is included in the result.
15 | *
16 | * @param data A map containing the current field values of the form.
17 | * @return A list of controls corresponding to visible elements in the [UiSchema].
18 | */
19 | internal fun UiSchema.findVisibleControls(data: Map): List =
20 | when (this) {
21 | is GroupLayout, is HorizontalLayout, is VerticalLayout -> {
22 | elements?.flatMap { it.findVisibleControls(data) } ?: emptyList()
23 | }
24 | is Control -> {
25 | if (rule == null || rule?.evaluateShow(data) == true) {
26 | listOfNotNull(this)
27 | } else {
28 | emptyList()
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/ui/src/commonMain/kotlin/com/paligot/jsonforms/ui/RendererBooleanScope.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.ui
2 |
3 | import androidx.compose.runtime.Stable
4 | import com.paligot.jsonforms.kotlin.SchemaProvider
5 | import com.paligot.jsonforms.kotlin.internal.ext.isEnabled
6 | import com.paligot.jsonforms.kotlin.internal.ext.isToggle
7 | import com.paligot.jsonforms.kotlin.internal.ext.label
8 | import com.paligot.jsonforms.kotlin.models.schema.BooleanProperty
9 | import com.paligot.jsonforms.kotlin.models.uischema.Control
10 |
11 | @Stable
12 | interface RendererBooleanScope {
13 | fun isToggle(): Boolean
14 |
15 | fun label(): String?
16 |
17 | fun description(): String?
18 |
19 | fun enabled(): Boolean
20 | }
21 |
22 | internal class RendererBooleanScopeInstance(
23 | private val control: Control,
24 | private val schemaProvider: SchemaProvider,
25 | private val jsonFormState: JsonFormState,
26 | private val property: BooleanProperty = schemaProvider.getPropertyByControl(control),
27 | ) : RendererBooleanScope {
28 | override fun isToggle(): Boolean = property.isToggle(control)
29 |
30 | override fun label(): String? =
31 | property
32 | .label(
33 | required = schemaProvider.propertyIsRequired(control, jsonFormState.getData()),
34 | control = control,
35 | )
36 |
37 | override fun description(): String? = property.description
38 |
39 | override fun enabled(): Boolean = property.isEnabled(control, jsonFormState.getData())
40 | }
41 |
--------------------------------------------------------------------------------
/renderers/cupertino/src/commonMain/kotlin/com/paligot/jsonforms/cupertino/ui/Checkbox.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.cupertino.ui
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Alignment
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.unit.dp
10 | import com.slapps.cupertino.CupertinoCheckBox
11 | import com.slapps.cupertino.CupertinoText
12 |
13 | @Composable
14 | internal fun Checkbox(
15 | value: Boolean,
16 | modifier: Modifier = Modifier,
17 | label: String? = null,
18 | enabled: Boolean = true,
19 | onCheckedChange: ((Boolean) -> Unit),
20 | ) {
21 | Row(
22 | modifier =
23 | if (label != null) {
24 | modifier.clickable { onCheckedChange(!value) }
25 | } else {
26 | modifier
27 | },
28 | verticalAlignment = Alignment.CenterVertically,
29 | horizontalArrangement = Arrangement.spacedBy(4.dp),
30 | ) {
31 | CupertinoCheckBox(
32 | checked = value,
33 | onCheckedChange =
34 | if (label == null) {
35 | { onCheckedChange(it) }
36 | } else {
37 | null
38 | },
39 | enabled = enabled,
40 | )
41 | if (label != null) {
42 | CupertinoText(text = label)
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/checks/StringProperty.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.checks
2 |
3 | import com.paligot.jsonforms.kotlin.internal.FieldError
4 | import com.paligot.jsonforms.kotlin.internal.ext.value
5 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
6 |
7 | /**
8 | * Validates the given string value against the constraints defined in the `StringProperty`.
9 | *
10 | * @param scopeKey The scope key of the property being validated.
11 | * @param value The string value to validate.
12 | * @return A list of [FieldError] objects representing validation errors, or an empty list if the value is valid.
13 | */
14 | internal fun StringProperty.validate(
15 | scopeKey: String,
16 | value: String,
17 | ): List {
18 | val errors = mutableListOf()
19 | if (value.length < (minLength ?: 0)) {
20 | errors.add(FieldError.MinLengthFieldError(minLength ?: 0, scopeKey))
21 | }
22 | if (maxLength != null && value.length > maxLength) {
23 | errors.add(FieldError.MaxLengthFieldError(maxLength, scopeKey))
24 | }
25 | if (enum != null && !enum.contains(value)) {
26 | errors.add(FieldError.InvalidEnumFieldError(enum, scopeKey))
27 | }
28 | if (oneOf != null && oneOf.mapNotNull { it.const?.value() }.contains(value)) {
29 | errors.add(
30 | FieldError.InvalidEnumFieldError(oneOf.mapNotNull { it.const?.content }, scopeKey),
31 | )
32 | }
33 | errors.addAll(this.validateProperty(scopeKey, value))
34 | return errors
35 | }
36 |
--------------------------------------------------------------------------------
/renderers/cupertino/src/commonMain/kotlin/com/paligot/jsonforms/cupertino/ui/WheelPicker.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.cupertino.ui
2 |
3 | import androidx.compose.foundation.layout.fillMaxWidth
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.LaunchedEffect
6 | import androidx.compose.ui.Modifier
7 | import com.paligot.jsonforms.kotlin.internal.ext.value
8 | import com.paligot.jsonforms.kotlin.models.schema.Property
9 | import com.slapps.cupertino.CupertinoText
10 | import com.slapps.cupertino.CupertinoWheelPicker
11 | import com.slapps.cupertino.ExperimentalCupertinoApi
12 | import com.slapps.cupertino.rememberCupertinoPickerState
13 | import kotlinx.collections.immutable.ImmutableList
14 |
15 | @OptIn(ExperimentalCupertinoApi::class)
16 | @Composable
17 | internal fun WheelPicker(
18 | value: String?,
19 | values: ImmutableList,
20 | modifier: Modifier = Modifier,
21 | enabled: Boolean = true,
22 | onValueChange: (String) -> Unit,
23 | ) {
24 | val state = rememberCupertinoPickerState(infinite = false)
25 | LaunchedEffect(value) {
26 | val index = values.indexOfFirst { it.const?.value() == value }
27 | if (index != -1) {
28 | state.scrollToItem(index)
29 | }
30 | }
31 | LaunchedEffect(state.selectedItemIndex) {
32 | onValueChange(values[state.selectedItemIndex].const?.value() ?: "")
33 | }
34 | CupertinoWheelPicker(
35 | modifier = modifier.fillMaxWidth(),
36 | state = state,
37 | items = values.map { it.title ?: "" },
38 | enabled = enabled,
39 | content = { CupertinoText(it) },
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/SchemaProvider.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin
2 |
3 | import com.paligot.jsonforms.kotlin.internal.checks.propertyIsRequired
4 | import com.paligot.jsonforms.kotlin.internal.queries.getPropertyByControl
5 | import com.paligot.jsonforms.kotlin.internal.queries.isLastField
6 | import com.paligot.jsonforms.kotlin.models.schema.Property
7 | import com.paligot.jsonforms.kotlin.models.schema.Schema
8 | import com.paligot.jsonforms.kotlin.models.uischema.Control
9 | import com.paligot.jsonforms.kotlin.models.uischema.UiSchema
10 |
11 | interface SchemaProvider {
12 | /**
13 | * Get schema property from the key contained in a [Control].
14 | */
15 | fun getPropertyByControl(control: Control): T
16 |
17 | /**
18 | * Check if the control is specified as required in schema.
19 | */
20 | fun propertyIsRequired(
21 | control: Control,
22 | data: Map,
23 | ): Boolean
24 |
25 | /**
26 | * Check if the control is the last field in the ui-schema.
27 | */
28 | fun isLastField(control: Control): Boolean
29 | }
30 |
31 | class SchemaProviderImpl(private val uiSchema: UiSchema, private val schema: Schema) :
32 | SchemaProvider {
33 | override fun getPropertyByControl(control: Control): T = schema.getPropertyByControl(control)
34 |
35 | override fun propertyIsRequired(
36 | control: Control,
37 | data: Map,
38 | ): Boolean = schema.propertyIsRequired(control, data)
39 |
40 | override fun isLastField(control: Control): Boolean = control.isLastField(uiSchema, schema)
41 | }
42 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/ControlPropertyPathTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.uischema.Control
4 | import kotlin.test.Test
5 | import kotlin.test.assertContentEquals
6 | import kotlin.test.assertFailsWith
7 |
8 | class ControlPropertyPathTest {
9 | @Test
10 | fun `propertyPath should return single key for simple scope`() {
11 | val control = Control(scope = "#/properties/key")
12 | val result = control.propertyPath()
13 | assertContentEquals(arrayOf("key"), result)
14 | }
15 |
16 | @Test
17 | fun `propertyPath should return multiple keys for nested scope`() {
18 | val control = Control(scope = "#/properties/key1/properties/key2")
19 | val result = control.propertyPath()
20 | assertContentEquals(arrayOf("key1", "key2"), result)
21 | }
22 |
23 | @Test
24 | fun `propertyPath should throw an error for malformed scope`() {
25 | val control = Control(scope = "invalid_scope")
26 | assertFailsWith { control.propertyPath() }
27 | }
28 |
29 | @Test
30 | fun `propertyPath should handle scope with multiple nested properties`() {
31 | val control = Control(scope = "#/properties/key1/properties/key2/properties/key3")
32 | val result = control.propertyPath()
33 | assertContentEquals(arrayOf("key1", "key2", "key3"), result)
34 | }
35 |
36 | @Test
37 | fun `propertyPath should throw an error for empty scope`() {
38 | val control = Control(scope = "")
39 | assertFailsWith { control.propertyPath() }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/schema/StringProperty.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.schema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.ImmutableListSerializer
4 | import com.paligot.jsonforms.kotlin.models.serializers.RegexSerializer
5 | import kotlinx.collections.immutable.ImmutableList
6 | import kotlinx.serialization.SerialName
7 | import kotlinx.serialization.Serializable
8 | import kotlinx.serialization.json.JsonPrimitive
9 |
10 | /**
11 | * A property which configure a field with a string value.
12 | */
13 | @Serializable
14 | @SerialName("string")
15 | data class StringProperty(
16 | override val title: String? = null,
17 | override val format: String? = null,
18 | override val description: String? = null,
19 | override val readOnly: Boolean? = null,
20 | override val const: JsonPrimitive? = null,
21 | override val not: Property? = null,
22 | @Serializable(with = RegexSerializer::class)
23 | override val pattern: Regex? = null,
24 | /**
25 | * An optional minimum length to validate the string value.
26 | */
27 | val minLength: Int? = null,
28 | /**
29 | * An optional maximum length to validate the string value.
30 | */
31 | val maxLength: Int? = null,
32 | /**
33 | * An optional enum value to restrict the value of the property.
34 | */
35 | @Serializable(with = ImmutableListSerializer::class)
36 | val enum: ImmutableList? = null,
37 | /**
38 | * An optional list of key-value to validate the string value.
39 | */
40 | @Serializable(with = ImmutableListSerializer::class)
41 | val oneOf: ImmutableList? = null,
42 | ) : Property()
43 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/StringPropertyIsPhoneTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Control
5 | import com.paligot.jsonforms.kotlin.models.uischema.ControlOptions
6 | import com.paligot.jsonforms.kotlin.models.uischema.Format
7 | import kotlin.test.Test
8 | import kotlin.test.assertFalse
9 | import kotlin.test.assertTrue
10 |
11 | class StringPropertyIsPhoneTest {
12 | @Test
13 | fun `isPhone should return true when format is Phone`() {
14 | val property = StringProperty()
15 | val control =
16 | Control(
17 | scope = "#/properties/key",
18 | options = ControlOptions(format = Format.Phone),
19 | )
20 |
21 | val result = property.isPhone(control)
22 |
23 | assertTrue(result)
24 | }
25 |
26 | @Test
27 | fun `isPhone should return false when format is not Phone`() {
28 | val property = StringProperty()
29 | val control =
30 | Control(
31 | scope = "#/properties/key",
32 | options = ControlOptions(format = Format.Email),
33 | )
34 |
35 | val result = property.isPhone(control)
36 |
37 | assertFalse(result)
38 | }
39 |
40 | @Test
41 | fun `isPhone should return false when control options are null`() {
42 | val property = StringProperty()
43 | val control = Control(scope = "#/properties/key", options = null)
44 |
45 | val result = property.isPhone(control)
46 |
47 | assertFalse(result)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/StringPropertyIsEmailTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Control
5 | import com.paligot.jsonforms.kotlin.models.uischema.ControlOptions
6 | import com.paligot.jsonforms.kotlin.models.uischema.Format
7 | import kotlin.test.Test
8 | import kotlin.test.assertFalse
9 | import kotlin.test.assertTrue
10 |
11 | class StringPropertyIsEmailTest {
12 | @Test
13 | fun `isEmail should return true when format is Email`() {
14 | val property = StringProperty()
15 | val control =
16 | Control(
17 | scope = "#/properties/key",
18 | options = ControlOptions(format = Format.Email),
19 | )
20 |
21 | val result = property.isEmail(control)
22 |
23 | assertTrue(result)
24 | }
25 |
26 | @Test
27 | fun `isEmail should return false when format is not Email`() {
28 | val property = StringProperty()
29 | val control =
30 | Control(
31 | scope = "#/properties/key",
32 | options = ControlOptions(format = Format.Password),
33 | )
34 |
35 | val result = property.isEmail(control)
36 |
37 | assertFalse(result)
38 | }
39 |
40 | @Test
41 | fun `isEmail should return false when control options are null`() {
42 | val property = StringProperty()
43 | val control = Control(scope = "#/properties/key", options = null)
44 |
45 | val result = property.isEmail(control)
46 |
47 | assertFalse(result)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/FieldError.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.Property
4 | import kotlinx.serialization.json.JsonPrimitive
5 |
6 | sealed class FieldError(val scope: String, val message: String) {
7 | class RequiredFieldError(scope: String) : FieldError(scope, "Field required $scope")
8 |
9 | class InvalidValueFieldError(val value: JsonPrimitive, scope: String) :
10 | FieldError(scope, "Field $scope has invalid value $value")
11 |
12 | class InvalidEnumFieldError(val enum: List, scope: String) :
13 | FieldError(scope, "Field $scope must be one of ${enum.joinToString(",")}")
14 |
15 | class PatternFieldError(val pattern: String, scope: String) :
16 | FieldError(scope, "Field $scope must match pattern $pattern")
17 |
18 | class MaxLengthFieldError(val maxLength: Int, scope: String) :
19 | FieldError(scope, "Field $scope must have at most $maxLength characters")
20 |
21 | class MinLengthFieldError(val minLength: Int, scope: String) :
22 | FieldError(scope, "Field $scope must have at least $minLength characters")
23 |
24 | class MinValueFieldError(val minValue: Int, scope: String) :
25 | FieldError(scope, "Field $scope must have at least $minValue")
26 |
27 | class MaxValueFieldError(val maxValue: Int, scope: String) :
28 | FieldError(scope, "Field $scope must have at most $maxValue")
29 |
30 | class MalformedFieldError(scope: String) : FieldError(scope, "Field malformed $scope")
31 |
32 | class InvalidNotPropertyError(val not: Property, scope: String) :
33 | FieldError(scope, "Field $scope must not be $not")
34 | }
35 |
--------------------------------------------------------------------------------
/ui/src/commonMain/kotlin/com/paligot/jsonforms/ui/Layout.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.ui
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.remember
6 | import com.paligot.jsonforms.kotlin.internal.ext.evaluateShow
7 | import com.paligot.jsonforms.kotlin.models.uischema.Control
8 | import com.paligot.jsonforms.kotlin.models.uischema.GroupLayout
9 | import com.paligot.jsonforms.kotlin.models.uischema.HorizontalLayout
10 | import com.paligot.jsonforms.kotlin.models.uischema.UiSchema
11 | import com.paligot.jsonforms.kotlin.models.uischema.VerticalLayout
12 |
13 | @Composable
14 | internal fun Layout(
15 | uiSchema: UiSchema,
16 | jsonFormState: JsonFormState,
17 | layoutContent: @Composable (RendererLayoutScope.(@Composable (UiSchema) -> Unit) -> Unit),
18 | content: @Composable (Control) -> Unit,
19 | ) {
20 | val hidden = uiSchema.rule?.evaluateShow(jsonFormState.getData())?.not() ?: false
21 | AnimatedVisibility(visible = !hidden) {
22 | when (uiSchema) {
23 | is VerticalLayout, is HorizontalLayout, is GroupLayout -> {
24 | val scope = remember(uiSchema) { RendererLayoutScopeInstance(uiSchema) }
25 | scope.layoutContent { child ->
26 | Layout(
27 | uiSchema = child,
28 | jsonFormState = jsonFormState,
29 | layoutContent = layoutContent,
30 | content = content,
31 | )
32 | }
33 | }
34 |
35 | is Control -> content.invoke(uiSchema)
36 | else -> TODO("Unsupported layout")
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/StringPropertyIsPasswordTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Control
5 | import com.paligot.jsonforms.kotlin.models.uischema.ControlOptions
6 | import com.paligot.jsonforms.kotlin.models.uischema.Format
7 | import kotlin.test.Test
8 | import kotlin.test.assertFalse
9 | import kotlin.test.assertTrue
10 |
11 | class StringPropertyIsPasswordTest {
12 | @Test
13 | fun `isPassword should return true when format is Password`() {
14 | val property = StringProperty()
15 | val control =
16 | Control(
17 | scope = "#/properties/key",
18 | options = ControlOptions(format = Format.Password),
19 | )
20 |
21 | val result = property.isPassword(control)
22 |
23 | assertTrue(result)
24 | }
25 |
26 | @Test
27 | fun `isPassword should return false when format is not Password`() {
28 | val property = StringProperty()
29 | val control =
30 | Control(
31 | scope = "#/properties/key",
32 | options = ControlOptions(format = Format.Email),
33 | )
34 |
35 | val result = property.isPassword(control)
36 |
37 | assertFalse(result)
38 | }
39 |
40 | @Test
41 | fun `isPassword should return false when control options are null`() {
42 | val property = StringProperty()
43 | val control = Control(scope = "#/properties/key", options = null)
44 |
45 | val result = property.isPassword(control)
46 |
47 | assertFalse(result)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | `jsonforms-kotlin` is a Kotlin Multiplatform implementation of the JSONForms standard from the
2 | Eclipse Foundation. It leverages Compose Multiplatform to render dynamic forms based on
3 | JSON Schemas and UI Schemas.
4 |
5 | This project is built with Kotlin Multiplatform and Compose Multiplatform.
6 |
7 | | Platform | Supported |
8 | |---------------|-----------|
9 | | Android | ✅ |
10 | | Desktop (JVM) | ✅ |
11 | | iOS | ✅ |
12 | | Wasm | ❌ |
13 | | JS/Canvas | ❌ |
14 |
15 | ## Download
16 |
17 | The `jsonforms-kotlin` library is not yet available on Maven Central in release mode but you can
18 | already use the latest SNAPSHOT version, `1.0.0-SNAPSHOT`:
19 |
20 | ```kotlin
21 | val version = "1.0.0-SNAPSHOT"
22 | dependencies {
23 | implementation("com.paligot.jsonforms.kotlin:shared:$version")
24 | implementation("com.paligot.jsonforms.kotlin:ui:$version")
25 | implementation("com.paligot.jsonforms.kotlin:material3:$version")
26 | implementation("com.paligot.jsonforms.kotlin:cupertino:$version")
27 | }
28 | ```
29 |
30 | ## License
31 |
32 | ```
33 | Copyright 2025 Gérard Paligot.
34 |
35 | Licensed under the Apache License, Version 2.0 (the "License");
36 | you may not use this file except in compliance with the License.
37 | You may obtain a copy of the License at
38 |
39 | http://www.apache.org/licenses/LICENSE-2.0
40 |
41 | Unless required by applicable law or agreed to in writing, software
42 | distributed under the License is distributed on an "AS IS" BASIS,
43 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
44 | See the License for the specific language governing permissions and
45 | limitations under the License.
46 | ```
47 |
48 |
--------------------------------------------------------------------------------
/renderers/cupertino/README.md:
--------------------------------------------------------------------------------
1 | # Cupertino Renderer
2 |
3 | This module provides a Cupertino-style renderer. It enables you to render dynamic forms using
4 | Cupertino (Apple-like) components, fully compatible with Kotlin Multiplatform and Compose
5 | Multiplatform projects.
6 |
7 | ## Usage
8 |
9 | Add the dependency to your project:
10 |
11 | ```kotlin
12 | dependencies {
13 | implementation("com.paligot.jsonforms.kotlin:cupertino:")
14 | }
15 | ```
16 |
17 | In your Compose code, use the Cupertino renderer functions in the `JsonForm` component:
18 |
19 | ```kotlin
20 | import com.paligot.jsonforms.cupertino.*
21 |
22 | JsonForm(
23 | schema = schema,
24 | uiSchema = uiSchema,
25 | state = formState,
26 | layoutContent = { CupertinoLayout(content = it) },
27 | stringContent = { scope ->
28 | CupertinoStringProperty(
29 | value = formState[scope.id].value as String?,
30 | error = formState.error(scope.id).value,
31 | onValueChange = { formState[scope.id] = it }
32 | )
33 | },
34 | numberContent = { scope ->
35 | CupertinoNumberProperty(
36 | value = formState[scope.id].value as String?,
37 | error = formState.error(scope.id).value,
38 | onValueChange = { formState[scope.id] = it }
39 | )
40 | },
41 | booleanContent = { scope ->
42 | CupertinoBooleanProperty(
43 | value = formState[scope.id].value as Boolean? ?: false,
44 | onValueChange = { formState[scope.id] = it }
45 | )
46 | }
47 | )
48 | ```
49 |
50 | ## Reference
51 |
52 | - [Usage guide](../../docs/usage.md)
53 | - [Custom rendering guide](../../docs/custom-rendering.md)
54 | - [Create a renderer guide](../../docs/create-renderer.md)
55 |
--------------------------------------------------------------------------------
/renderers/material3/README.md:
--------------------------------------------------------------------------------
1 | # Material3 Renderer
2 |
3 | This module provides a Material Design 3 (Material You) renderer. It enables you to render dynamic
4 | forms using Material3 components, fully compatible with Kotlin Multiplatform and Compose
5 | Multiplatform projects.
6 |
7 | ## Usage
8 |
9 | Add the dependency to your project:
10 |
11 | ```kotlin
12 | dependencies {
13 | implementation("com.paligot.jsonforms.kotlin:material3:")
14 | }
15 | ```
16 |
17 | In your Compose code, use the Material3 renderer functions in the `JsonForm` component:
18 |
19 | ```kotlin
20 | import com.paligot.jsonforms.material3.*
21 |
22 | JsonForm(
23 | schema = schema,
24 | uiSchema = uiSchema,
25 | state = formState,
26 | layoutContent = { Material3Layout(content = it) },
27 | stringContent = { scope ->
28 | Material3StringProperty(
29 | value = formState[scope.id].value as String?,
30 | error = formState.error(scope.id).value,
31 | onValueChange = { formState[scope.id] = it }
32 | )
33 | },
34 | numberContent = { scope ->
35 | Material3NumberProperty(
36 | value = formState[scope.id].value as String?,
37 | error = formState.error(scope.id).value,
38 | onValueChange = { formState[scope.id] = it }
39 | )
40 | },
41 | booleanContent = { scope ->
42 | Material3BooleanProperty(
43 | value = formState[scope.id].value as Boolean? ?: false,
44 | onValueChange = { formState[scope.id] = it }
45 | )
46 | }
47 | )
48 | ```
49 |
50 | ## Reference
51 |
52 | - [Usage guide](../../docs/usage.md)
53 | - [Custom rendering guide](../../docs/custom-rendering.md)
54 | - [Create a renderer guide](../../docs/create-renderer.md)
55 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/checks/Property.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.checks
2 |
3 | import com.paligot.jsonforms.kotlin.internal.FieldError
4 | import com.paligot.jsonforms.kotlin.internal.ext.anyValue
5 | import com.paligot.jsonforms.kotlin.internal.ext.toJsonPrimitive
6 | import com.paligot.jsonforms.kotlin.models.schema.Property
7 |
8 | /**
9 | * Validates the given value against the constraints defined in the `Property`.
10 | *
11 | * This function checks various constraints such as `const`, `pattern`, and `not`
12 | * defined in the `Property` and returns a list of validation errors if the value
13 | * does not meet the constraints. If the value is valid, an empty list is returned.
14 | *
15 | * @param scopeKey The scope key of the property being validated.
16 | * @param value The value to validate.
17 | * @return A list of [FieldError] objects representing validation errors, or an empty list if the value is valid.
18 | */
19 | internal fun Property.validateProperty(
20 | scopeKey: String,
21 | value: Any,
22 | ): List {
23 | val errors = mutableListOf()
24 | if (this.const != null) {
25 | if (this.const!!.anyValue != value.toJsonPrimitive()?.anyValue) {
26 | errors.add(FieldError.InvalidValueFieldError(value.toJsonPrimitive()!!, scopeKey))
27 | }
28 | }
29 |
30 | if (pattern != null) {
31 | if (!pattern!!.matches(value.toString())) {
32 | errors.add(FieldError.PatternFieldError(pattern!!.pattern, scopeKey))
33 | }
34 | }
35 |
36 | if (not != null) {
37 | val notValue = not!!.validateProperty(scopeKey, value)
38 | if (notValue.isEmpty()) {
39 | errors.add(FieldError.InvalidNotPropertyError(not!!, scopeKey))
40 | }
41 | }
42 |
43 | return errors
44 | }
45 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/queries/ObjectProperty.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.queries
2 |
3 | import com.paligot.jsonforms.kotlin.internal.ext.propertyPath
4 | import com.paligot.jsonforms.kotlin.models.schema.ObjectProperty
5 | import com.paligot.jsonforms.kotlin.models.schema.Property
6 | import com.paligot.jsonforms.kotlin.models.schema.Schema
7 | import com.paligot.jsonforms.kotlin.models.uischema.Control
8 |
9 | /**
10 | * Get [Property] in [Schema] from scope property declared in the [Control].
11 | * It will search inside the schema, according to the path of the scope, the last key in the scope.
12 | * If it finds a property which isn't an object, it will return it. Otherwise, an error is thrown.
13 | *
14 | * ```kotlin
15 | * val schema = Schema(
16 | * properties = persistentMapOf("key" to StringProperty())
17 | * )
18 | * val control = Control(scope = "#/properties/key")
19 | * val property = schema.getPropertyByControl(control)
20 | * ```
21 | *
22 | * @param control Field contained in the UiSchema.
23 | * @return property from the schema.
24 | */
25 | internal fun ObjectProperty.getPropertyByControl(control: Control): T {
26 | val propertyPath = control.propertyPath()
27 | var objectProperty: ObjectProperty = this
28 | for (key in propertyPath) {
29 | val property =
30 | objectProperty.properties[key]
31 | ?: error("Property $key not found in schema")
32 | if (property is ObjectProperty) {
33 | objectProperty = property
34 | } else if (propertyPath.last() == key) {
35 | return property as T
36 | } else {
37 | error("$key is not the latest key in the path and isn't an object")
38 | }
39 | }
40 | error("Can't find property with control $control")
41 | }
42 |
--------------------------------------------------------------------------------
/.github/workflows/publish-docs.yaml:
--------------------------------------------------------------------------------
1 | name: Publish Documentation
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | permissions:
10 | contents: read
11 | pages: write
12 | id-token: write
13 |
14 | jobs:
15 | build:
16 | permissions:
17 | checks: write # for actions/upload-artifact
18 | runs-on: ubuntu-latest
19 | steps:
20 | - uses: actions/checkout@v4
21 |
22 | - uses: actions/setup-java@v4
23 | with:
24 | distribution: temurin
25 | java-version: 21
26 |
27 | - uses: actions/setup-python@v5
28 | with:
29 | python-version: '3.x'
30 |
31 | - uses: gradle/actions/setup-gradle@v4
32 | with:
33 | gradle-version: wrapper
34 | build-scan-publish: true
35 | build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
36 | build-scan-terms-of-use-agree: "yes"
37 |
38 | - name: Install dependencies
39 | run: |
40 | python3 -m pip install --upgrade pip
41 | python3 -m pip install mkdocs mkdocs-material
42 |
43 | - name: Generate Docs
44 | run: ./scripts/build_docs.sh
45 |
46 | - uses: actions/upload-pages-artifact@v3
47 | id: deployment
48 | with:
49 | path: site/
50 |
51 | deploy:
52 | if: github.ref == 'refs/heads/main'
53 | needs: build
54 |
55 | permissions:
56 | pages: write # to deploy to Pages
57 | id-token: write # to verify the deployment originates from an appropriate source
58 |
59 | # Deploy to the github-pages environment
60 | environment:
61 | name: github-pages
62 | url: ${{ steps.deployment.outputs.page_url }}
63 |
64 | runs-on: ubuntu-latest
65 | steps:
66 | - name: Deploy to GitHub Pages
67 | id: deployment
68 | uses: actions/deploy-pages@v4
69 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/ui/FormScaffold.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.ui
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.BoxScope
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material.icons.Icons
8 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
9 | import androidx.compose.material3.CenterAlignedTopAppBar
10 | import androidx.compose.material3.ExperimentalMaterial3Api
11 | import androidx.compose.material3.Icon
12 | import androidx.compose.material3.IconButton
13 | import androidx.compose.material3.Scaffold
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 |
19 | @OptIn(ExperimentalMaterial3Api::class)
20 | @Composable
21 | fun FormScaffold(
22 | title: String,
23 | onBackClick: () -> Unit,
24 | modifier: Modifier = Modifier,
25 | content: @Composable BoxScope.() -> Unit,
26 | ) {
27 | Scaffold(
28 | modifier = modifier,
29 | topBar = {
30 | CenterAlignedTopAppBar(
31 | title = { Text(text = title) },
32 | navigationIcon = {
33 | IconButton(onClick = onBackClick) {
34 | Icon(
35 | imageVector = Icons.AutoMirrored.Filled.ArrowBack,
36 | contentDescription = "Back",
37 | )
38 | }
39 | },
40 | )
41 | },
42 | ) {
43 | Box(
44 | contentAlignment = Alignment.TopCenter,
45 | modifier = Modifier.padding(it).fillMaxSize(),
46 | content = content,
47 | )
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/serializers/ObjectPropertyListSerializer.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.serializers
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.ObjectProperty
4 | import com.paligot.jsonforms.kotlin.models.schema.Property
5 | import kotlinx.serialization.DeserializationStrategy
6 | import kotlinx.serialization.KSerializer
7 | import kotlinx.serialization.builtins.ListSerializer
8 | import kotlinx.serialization.descriptors.SerialDescriptor
9 | import kotlinx.serialization.encoding.Decoder
10 | import kotlinx.serialization.encoding.Encoder
11 | import kotlinx.serialization.json.JsonArray
12 | import kotlinx.serialization.json.JsonContentPolymorphicSerializer
13 | import kotlinx.serialization.json.JsonElement
14 | import kotlin.reflect.KClass
15 |
16 | @Suppress("UNCHECKED_CAST")
17 | class ObjectPropertyListSerializer : JsonContentPolymorphicSerializer>(
18 | List::class as KClass>,
19 | ) {
20 | override fun selectDeserializer(element: JsonElement): DeserializationStrategy> {
21 | return if (element is JsonArray) {
22 | ListSerializer(ObjectProperty.serializer())
23 | } else {
24 | SingleObjectPropertyListSerializer()
25 | }
26 | }
27 |
28 | class SingleObjectPropertyListSerializer : KSerializer> {
29 | override val descriptor: SerialDescriptor
30 | get() = Property.serializer().descriptor
31 |
32 | override fun deserialize(decoder: Decoder): List {
33 | return listOf(ObjectProperty.serializer().deserialize(decoder))
34 | }
35 |
36 | override fun serialize(
37 | encoder: Encoder,
38 | value: List,
39 | ) {
40 | throw Exception("Not in use")
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/renderers/material3/src/commonMain/kotlin/com/paligot/jsonforms/material3/ui/Switch.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.material3.ui
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.material3.Text
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 |
13 | @Composable
14 | internal fun Switch(
15 | value: Boolean,
16 | modifier: Modifier,
17 | label: String? = null,
18 | description: String? = null,
19 | enabled: Boolean = true,
20 | onCheckedChange: (Boolean) -> Unit,
21 | ) {
22 | Row(
23 | modifier = modifier.fillMaxWidth(),
24 | horizontalArrangement = Arrangement.SpaceBetween,
25 | verticalAlignment = Alignment.CenterVertically,
26 | ) {
27 | Column(
28 | modifier = Modifier.weight(1f),
29 | content = {
30 | val hasLabel = !label.isNullOrEmpty()
31 | val hasDescription = !description.isNullOrEmpty()
32 | if (!hasLabel && hasDescription) {
33 | description?.let { Text(text = it) }
34 | } else {
35 | Text(
36 | text = label ?: "",
37 | style = MaterialTheme.typography.titleMedium,
38 | )
39 | if (hasDescription) {
40 | description?.let { Text(text = it) }
41 | }
42 | }
43 | },
44 | )
45 |
46 | androidx.compose.material3.Switch(
47 | checked = value,
48 | onCheckedChange = onCheckedChange,
49 | enabled = enabled,
50 | )
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/renderers/cupertino/src/commonMain/kotlin/com/paligot/jsonforms/cupertino/ui/Switch.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.cupertino.ui
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import com.slapps.cupertino.CupertinoSwitch
11 | import com.slapps.cupertino.CupertinoText
12 | import com.slapps.cupertino.theme.CupertinoTheme
13 |
14 | @Composable
15 | internal fun Switch(
16 | value: Boolean,
17 | modifier: Modifier,
18 | label: String? = null,
19 | description: String? = null,
20 | enabled: Boolean = true,
21 | onCheckedChange: (Boolean) -> Unit,
22 | ) {
23 | Row(
24 | modifier = modifier.fillMaxWidth(),
25 | horizontalArrangement = Arrangement.SpaceBetween,
26 | verticalAlignment = Alignment.CenterVertically,
27 | ) {
28 | Column(
29 | modifier = Modifier.weight(1f),
30 | content = {
31 | val hasLabel = !label.isNullOrEmpty()
32 | val hasDescription = !description.isNullOrEmpty()
33 | if (!hasLabel && hasDescription) {
34 | description?.let { CupertinoText(text = it) }
35 | } else {
36 | CupertinoText(
37 | text = label ?: "",
38 | style = CupertinoTheme.typography.headline,
39 | )
40 | if (hasDescription) {
41 | description?.let { CupertinoText(text = it) }
42 | }
43 | }
44 | },
45 | )
46 |
47 | CupertinoSwitch(
48 | checked = value,
49 | onCheckedChange = onCheckedChange,
50 | enabled = enabled,
51 | )
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/ext/Control.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.uischema.Condition
4 | import com.paligot.jsonforms.kotlin.models.uischema.Control
5 |
6 | /**
7 | * Get key of the [Control] at the end of the path in the scope.
8 | *
9 | * ```kotlin
10 | * val control = Control(scope = "#/properties/key")
11 | * val key = control.propertyKey()
12 | * ```
13 | *
14 | * @return key of the [Control]
15 | */
16 | fun Control.propertyKey(): String = scope.propertyKey()
17 |
18 | /**
19 | * Get key of the [Condition] at the end of the path in the scope.
20 | *
21 | * ```kotlin
22 | * val condition = Condition(
23 | * scope = "#/properties/key",
24 | * schema = ConditionSchema(const = JsonPrimitive(""))
25 | * )
26 | * val key = condition.propertyKey()
27 | * ```
28 | *
29 | * @return key of the [Control]
30 | */
31 | internal fun Condition.propertyKey(): String = scope.propertyKey()
32 |
33 | /**
34 | * Split the property path from the scope property to an array of keys.
35 | * If your scope is `#/properties/key`, it will return ["key"]
36 | * but if your scope is `#/properties/key1/properties/key2`, it will return ["key1", "key2"]
37 | *
38 | * ```kotlin
39 | * val control = Control(scope = "#/properties/key1/properties/key2")
40 | * val keys = control.propertyPath()
41 | * ```
42 | *
43 | * @return list of keys
44 | */
45 | internal fun Control.propertyPath(): Array {
46 | if (!pathPattern.matches(scope)) error("Property $this malformed")
47 | val split = scope.split("/properties/")
48 | // Remove the first item which is '#' character.
49 | return split.slice(IntRange(1, split.size - 1)).toTypedArray()
50 | }
51 |
52 | private val pathPattern = Regex("^#(?:/properties/([a-zA-Z0-9_.-]+))+")
53 |
54 | private fun String.propertyKey(): String {
55 | if (!this.matches(pathPattern)) error("Property $this malformed")
56 | return split("/").last()
57 | }
58 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/models/schema/ObjectProperty.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.schema
2 |
3 | import com.paligot.jsonforms.kotlin.models.serializers.ImmutableListSerializer
4 | import com.paligot.jsonforms.kotlin.models.serializers.ImmutableMapSerializer
5 | import com.paligot.jsonforms.kotlin.models.serializers.RegexSerializer
6 | import kotlinx.collections.immutable.ImmutableList
7 | import kotlinx.collections.immutable.ImmutableMap
8 | import kotlinx.collections.immutable.persistentListOf
9 | import kotlinx.serialization.SerialName
10 | import kotlinx.serialization.Serializable
11 | import kotlinx.serialization.json.JsonPrimitive
12 | import kotlin.experimental.ExperimentalObjCName
13 | import kotlin.native.ObjCName
14 |
15 | /**
16 | * A property which configure a field with an object value.
17 | */
18 | @OptIn(ExperimentalObjCName::class)
19 | @Serializable
20 | @SerialName("object")
21 | @ObjCName("Schema")
22 | data class ObjectProperty(
23 | /**
24 | * The child properties of this property.
25 | */
26 | @Serializable(with = ImmutableMapSerializer::class)
27 | val properties: ImmutableMap,
28 | /**
29 | * An optional list of required properties.
30 | */
31 | @Serializable(with = ImmutableListSerializer::class)
32 | val required: ImmutableList = persistentListOf(),
33 | /**
34 | * An optional list of properties that are required if any of the properties in the
35 | * `anyOf` array are present.
36 | */
37 | @Serializable(with = ImmutableListSerializer::class)
38 | val anyOf: ImmutableList? = null,
39 | override val title: String? = null,
40 | override val format: String? = null,
41 | override val description: String? = null,
42 | override val readOnly: Boolean? = null,
43 | override val const: JsonPrimitive? = null,
44 | override val not: Property? = null,
45 | @Serializable(with = RegexSerializer::class)
46 | override val pattern: Regex? = null,
47 | ) : Property()
48 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/panes/FormListPane.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.panes
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.lazy.LazyColumn
5 | import androidx.compose.material3.ListItem
6 | import androidx.compose.material3.Scaffold
7 | import androidx.compose.material3.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 |
11 | @Composable
12 | fun FormListPane(
13 | modifier: Modifier = Modifier,
14 | onAccountCreationClick: () -> Unit,
15 | onLogInClick: () -> Unit,
16 | onContactClick: () -> Unit,
17 | onAddressClick: () -> Unit,
18 | onAppleClick: () -> Unit,
19 | ) {
20 | Scaffold(modifier = modifier) {
21 | LazyColumn(contentPadding = it) {
22 | item {
23 | ListItem(
24 | headlineContent = { Text(text = "Account creation") },
25 | modifier = Modifier.clickable(onClick = onAccountCreationClick),
26 | )
27 | }
28 | item {
29 | ListItem(
30 | headlineContent = { Text(text = "Log In") },
31 | modifier = Modifier.clickable(onClick = onLogInClick),
32 | )
33 | }
34 | item {
35 | ListItem(
36 | headlineContent = { Text(text = "Contact") },
37 | modifier = Modifier.clickable(onClick = onContactClick),
38 | )
39 | }
40 | item {
41 | ListItem(
42 | headlineContent = { Text(text = "Address") },
43 | modifier = Modifier.clickable(onClick = onAddressClick),
44 | )
45 | }
46 | item {
47 | ListItem(
48 | headlineContent = { Text(text = "Apple Form") },
49 | modifier = Modifier.clickable(onClick = onAppleClick),
50 | )
51 | }
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/ui/src/commonMain/kotlin/com/paligot/jsonforms/ui/RendererNumberScope.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.ui
2 |
3 | import androidx.compose.foundation.text.KeyboardOptions
4 | import androidx.compose.runtime.Stable
5 | import androidx.compose.ui.text.input.ImeAction
6 | import androidx.compose.ui.text.input.KeyboardCapitalization
7 | import androidx.compose.ui.text.input.KeyboardType
8 | import com.paligot.jsonforms.kotlin.SchemaProvider
9 | import com.paligot.jsonforms.kotlin.internal.ext.isEnabled
10 | import com.paligot.jsonforms.kotlin.internal.ext.label
11 | import com.paligot.jsonforms.kotlin.models.schema.NumberProperty
12 | import com.paligot.jsonforms.kotlin.models.uischema.Control
13 |
14 | @Stable
15 | interface RendererNumberScope {
16 | fun label(): String?
17 |
18 | fun description(): String?
19 |
20 | fun enabled(): Boolean
21 |
22 | fun keyboardOptions(
23 | capitalization: KeyboardCapitalization = KeyboardCapitalization.Unspecified,
24 | imeAction: ImeAction = ImeAction.Unspecified,
25 | ): KeyboardOptions
26 | }
27 |
28 | internal class RendererNumberScopeInstance(
29 | private val control: Control,
30 | private val schemaProvider: SchemaProvider,
31 | private val jsonFormState: JsonFormState,
32 | private val property: NumberProperty = schemaProvider.getPropertyByControl(control),
33 | ) : RendererNumberScope {
34 | override fun label(): String? =
35 | property
36 | .label(
37 | required = schemaProvider.propertyIsRequired(control, jsonFormState.getData()),
38 | control = control,
39 | )
40 |
41 | override fun description(): String? = property.description
42 |
43 | override fun enabled(): Boolean = property.isEnabled(control, jsonFormState.getData())
44 |
45 | override fun keyboardOptions(
46 | capitalization: KeyboardCapitalization,
47 | imeAction: ImeAction,
48 | ): KeyboardOptions =
49 | KeyboardOptions(
50 | capitalization = capitalization,
51 | keyboardType = KeyboardType.Number,
52 | imeAction = imeAction,
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/ui/MyApplicationTheme.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.ui
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.foundation.shape.RoundedCornerShape
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.material3.Shapes
7 | import androidx.compose.material3.Typography
8 | import androidx.compose.material3.darkColorScheme
9 | import androidx.compose.material3.lightColorScheme
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.text.TextStyle
13 | import androidx.compose.ui.text.font.FontFamily
14 | import androidx.compose.ui.text.font.FontWeight
15 | import androidx.compose.ui.unit.dp
16 | import androidx.compose.ui.unit.sp
17 |
18 | @Composable
19 | fun MyApplicationTheme(
20 | darkTheme: Boolean = isSystemInDarkTheme(),
21 | content: @Composable () -> Unit,
22 | ) {
23 | val colors =
24 | if (darkTheme) {
25 | darkColorScheme(
26 | primary = Color(0xFFBB86FC),
27 | secondary = Color(0xFF03DAC5),
28 | tertiary = Color(0xFF3700B3),
29 | )
30 | } else {
31 | lightColorScheme(
32 | primary = Color(0xFF6200EE),
33 | secondary = Color(0xFF03DAC5),
34 | tertiary = Color(0xFF3700B3),
35 | )
36 | }
37 | val typography =
38 | Typography(
39 | bodyMedium =
40 | TextStyle(
41 | fontFamily = FontFamily.Default,
42 | fontWeight = FontWeight.Normal,
43 | fontSize = 16.sp,
44 | ),
45 | )
46 | val shapes =
47 | Shapes(
48 | small = RoundedCornerShape(4.dp),
49 | medium = RoundedCornerShape(4.dp),
50 | large = RoundedCornerShape(0.dp),
51 | )
52 |
53 | MaterialTheme(
54 | colorScheme = colors,
55 | typography = typography,
56 | shapes = shapes,
57 | content = content,
58 | )
59 | }
60 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/FormDescriptionNavGraph.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin
2 |
3 | import androidx.navigation.NavController
4 | import androidx.navigation.NavGraphBuilder
5 | import androidx.navigation.compose.composable
6 | import com.paligot.jsonforms.kotlin.panes.AccountCreationFormPane
7 | import com.paligot.jsonforms.kotlin.panes.AppleForm
8 | import com.paligot.jsonforms.kotlin.panes.ContactFormPane
9 | import com.paligot.jsonforms.kotlin.panes.FormListPane
10 | import com.paligot.jsonforms.kotlin.panes.LoginFormPane
11 | import com.paligot.jsonforms.kotlin.panes.address.AddressFormVM
12 | import kotlinx.serialization.Serializable
13 |
14 | @Serializable
15 | object FormList
16 |
17 | @Serializable
18 | object AccountCreation
19 |
20 | @Serializable
21 | object Login
22 |
23 | @Serializable
24 | object Contact
25 |
26 | @Serializable
27 | object Address
28 |
29 | @Serializable
30 | object AppleForm
31 |
32 | fun NavGraphBuilder.formDescriptionNavGraph(navController: NavController) {
33 | composable {
34 | FormListPane(
35 | onAccountCreationClick = { navController.navigate(AccountCreation) },
36 | onLogInClick = { navController.navigate(Login) },
37 | onContactClick = { navController.navigate(Contact) },
38 | onAddressClick = { navController.navigate(Address) },
39 | onAppleClick = { navController.navigate(AppleForm) },
40 | )
41 | }
42 | composable {
43 | AccountCreationFormPane {
44 | navController.popBackStack()
45 | }
46 | }
47 | composable {
48 | LoginFormPane {
49 | navController.popBackStack()
50 | }
51 | }
52 | composable {
53 | ContactFormPane {
54 | navController.popBackStack()
55 | }
56 | }
57 | composable {
58 | AddressFormVM(onBackClick = {
59 | navController.popBackStack()
60 | })
61 | }
62 | composable {
63 | AppleForm {
64 | navController.popBackStack()
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/panes/address/geocode/GeocodeApi.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.panes.address.geocode
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.call.body
5 | import io.ktor.client.engine.cio.CIO
6 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
7 | import io.ktor.client.plugins.logging.LogLevel
8 | import io.ktor.client.plugins.logging.Logger
9 | import io.ktor.client.plugins.logging.Logging
10 | import io.ktor.client.plugins.logging.SIMPLE
11 | import io.ktor.client.request.get
12 | import io.ktor.serialization.kotlinx.json.json
13 | import kotlinx.coroutines.coroutineScope
14 | import kotlinx.serialization.json.Json
15 | import java.net.URLEncoder
16 |
17 | class GeocodeApi(
18 | private val client: HttpClient,
19 | private val baseUrl: String = "https://maps.googleapis.com/maps/api",
20 | ) {
21 | suspend fun geocode(query: String): Geocode =
22 | coroutineScope {
23 | val encodeQuery = URLEncoder.encode(query, "utf-8")
24 | val apiKey = ""
25 | return@coroutineScope client
26 | .get("$baseUrl/geocode/json?address=$encodeQuery&key=$apiKey")
27 | .body()
28 | }
29 |
30 | companion object {
31 | fun create(enableNetworkLogs: Boolean): GeocodeApi =
32 | GeocodeApi(
33 | client =
34 | HttpClient(CIO.create()) {
35 | install(ContentNegotiation) {
36 | json(
37 | Json {
38 | isLenient = true
39 | ignoreUnknownKeys = true
40 | },
41 | )
42 | }
43 | if (enableNetworkLogs) {
44 | install(Logging) {
45 | logger = Logger.SIMPLE
46 | level = LogLevel.INFO
47 | }
48 | }
49 | },
50 | )
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/renderers/material3/src/commonMain/kotlin/com/paligot/jsonforms/material3/ui/OutlinedTextField.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.material3.ui
2 |
3 | import androidx.compose.foundation.layout.fillMaxWidth
4 | import androidx.compose.foundation.text.KeyboardActions
5 | import androidx.compose.foundation.text.KeyboardOptions
6 | import androidx.compose.material3.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.focus.FocusDirection
10 | import androidx.compose.ui.platform.LocalFocusManager
11 | import androidx.compose.ui.text.input.VisualTransformation
12 |
13 | @Composable
14 | internal fun OutlinedTextField(
15 | value: String?,
16 | modifier: Modifier = Modifier,
17 | label: String? = null,
18 | description: String? = null,
19 | enabled: Boolean = true,
20 | error: String? = null,
21 | visualTransformation: VisualTransformation = VisualTransformation.None,
22 | keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
23 | onValueChange: (String) -> Unit,
24 | ) {
25 | val focusManager = LocalFocusManager.current
26 | val supportingText = error ?: description
27 | androidx.compose.material3.OutlinedTextField(
28 | value = value ?: "",
29 | onValueChange = onValueChange,
30 | modifier = modifier.fillMaxWidth(),
31 | enabled = enabled,
32 | isError = error != null,
33 | label =
34 | if (label != null) {
35 | { Text(text = label) }
36 | } else {
37 | null
38 | },
39 | supportingText =
40 | if (supportingText != null) {
41 | { Text(text = supportingText) }
42 | } else {
43 | null
44 | },
45 | keyboardOptions = keyboardOptions,
46 | keyboardActions =
47 | KeyboardActions(
48 | onNext = { focusManager.moveFocus(FocusDirection.Down) },
49 | onDone = { focusManager.clearFocus(true) },
50 | ),
51 | maxLines = 1,
52 | singleLine = true,
53 | visualTransformation = visualTransformation,
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/ControlPropertyKeyTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Condition
5 | import com.paligot.jsonforms.kotlin.models.uischema.Control
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 | import kotlin.test.assertFailsWith
9 |
10 | class ControlPropertyKeyTest {
11 | @Test
12 | fun `propertyKey should return the last key in the scope for Control`() {
13 | val control = Control(scope = "#/properties/key")
14 | val result = control.propertyKey()
15 | assertEquals("key", result)
16 | }
17 |
18 | @Test
19 | fun `propertyKey should return the last key in the scope for Condition`() {
20 | val condition = Condition(scope = "#/properties/key", schema = StringProperty())
21 | val result = condition.propertyKey()
22 | assertEquals("key", result)
23 | }
24 |
25 | @Test
26 | fun `propertyKey should handle nested properties in the scope for Control`() {
27 | val control = Control(scope = "#/properties/key1/properties/key2")
28 | val result = control.propertyKey()
29 | assertEquals("key2", result)
30 | }
31 |
32 | @Test
33 | fun `propertyKey should handle nested properties in the scope for Condition`() {
34 | val condition =
35 | Condition(scope = "#/properties/key1/properties/key2", schema = StringProperty())
36 | val result = condition.propertyKey()
37 | assertEquals("key2", result)
38 | }
39 |
40 | @Test
41 | fun `propertyKey should throw an error for malformed scope in Control`() {
42 | val control = Control(scope = "invalid_scope")
43 | assertFailsWith { control.propertyKey() }
44 | }
45 |
46 | @Test
47 | fun `propertyKey should throw an error for malformed scope in Condition`() {
48 | val condition = Condition(scope = "invalid_scope", schema = StringProperty())
49 | assertFailsWith { condition.propertyKey() }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/panes/address/AddressFormPane.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.panes.address
2 |
3 | import androidx.compose.foundation.layout.width
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.LaunchedEffect
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.unit.dp
8 | import com.paligot.jsonforms.kotlin.ui.FormScaffold
9 | import com.paligot.jsonforms.material3.Material3Layout
10 | import com.paligot.jsonforms.material3.Material3StringProperty
11 | import com.paligot.jsonforms.ui.JsonForm
12 | import com.paligot.jsonforms.ui.rememberJsonFormState
13 |
14 | @Composable
15 | fun AddressFormPane(
16 | uiModel: AddressUiModel,
17 | modifier: Modifier = Modifier,
18 | onStreetChange: (String) -> Unit,
19 | onBackClick: () -> Unit,
20 | ) {
21 | val state = rememberJsonFormState(initialValues = mutableMapOf())
22 | LaunchedEffect(uiModel.generatedAddress) {
23 | state["street"] = uiModel.generatedAddress?.street ?: ""
24 | state["city"] = uiModel.generatedAddress?.city ?: ""
25 | state["country"] = uiModel.generatedAddress?.country ?: ""
26 | }
27 | FormScaffold(
28 | title = "Address form",
29 | modifier = modifier,
30 | onBackClick = onBackClick,
31 | ) {
32 | JsonForm(
33 | modifier = Modifier.width(500.dp),
34 | schema = uiModel.schema,
35 | uiSchema = uiModel.uiSchema,
36 | state = state,
37 | layoutContent = { Material3Layout(content = it) },
38 | stringContent = { id ->
39 | val value = state[id].value as String?
40 | val error = state.error(id = id).value
41 | Material3StringProperty(
42 | value = value,
43 | error = error?.message,
44 | onValueChange = {
45 | state[id] = it
46 | onStreetChange(it)
47 | },
48 | )
49 | },
50 | numberContent = {},
51 | booleanContent = {},
52 | )
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/ui/src/commonMain/kotlin/com/paligot/jsonforms/ui/RendererLayoutScope.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.ui
2 |
3 | import androidx.compose.runtime.Stable
4 | import com.paligot.jsonforms.kotlin.models.uischema.GroupLayout
5 | import com.paligot.jsonforms.kotlin.models.uischema.HorizontalLayout
6 | import com.paligot.jsonforms.kotlin.models.uischema.UiSchema
7 | import com.paligot.jsonforms.kotlin.models.uischema.VerticalLayout
8 |
9 | @Stable
10 | interface RendererLayoutScope {
11 | fun isVerticalLayout(): Boolean
12 |
13 | fun isHorizontalLayout(): Boolean
14 |
15 | fun isGroupLayout(): Boolean
16 |
17 | fun title(): String?
18 |
19 | fun description(): String?
20 |
21 | fun elements(): List
22 |
23 | fun verticalSpacing(): String?
24 |
25 | fun horizontalSpacing(): String?
26 | }
27 |
28 | internal class RendererLayoutScopeInstance(
29 | private val uiSchema: UiSchema,
30 | ) : RendererLayoutScope {
31 | override fun isVerticalLayout(): Boolean = uiSchema is VerticalLayout
32 |
33 | override fun isHorizontalLayout(): Boolean = uiSchema is HorizontalLayout
34 |
35 | override fun isGroupLayout(): Boolean = uiSchema is GroupLayout
36 |
37 | override fun title(): String? = if (uiSchema is GroupLayout) uiSchema.label else null
38 |
39 | override fun description(): String? = if (uiSchema is GroupLayout) uiSchema.description else null
40 |
41 | override fun elements(): List = uiSchema.elements?.toList() ?: emptyList()
42 |
43 | override fun verticalSpacing(): String? =
44 | when (uiSchema) {
45 | is VerticalLayout -> uiSchema.options?.verticalSpacing
46 | is HorizontalLayout -> uiSchema.options?.verticalSpacing
47 | is GroupLayout -> uiSchema.options?.verticalSpacing
48 | else -> null
49 | }
50 |
51 | override fun horizontalSpacing(): String? =
52 | when (uiSchema) {
53 | is VerticalLayout -> uiSchema.options?.horizontalSpacing
54 | is HorizontalLayout -> uiSchema.options?.horizontalSpacing
55 | is GroupLayout -> uiSchema.options?.horizontalSpacing
56 | else -> null
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/checks/StringPropertyValidateTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.checks
2 |
3 | import com.paligot.jsonforms.kotlin.internal.FieldError
4 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
5 | import kotlinx.collections.immutable.persistentListOf
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 |
9 | class StringPropertyValidateTest {
10 | @Test
11 | fun `validate should return no errors for a valid string`() {
12 | val property = StringProperty(minLength = 3, maxLength = 10)
13 | val scopeKey = "key"
14 | val value = "valid"
15 |
16 | val result = property.validate(scopeKey, value)
17 |
18 | assertEquals(emptyList(), result)
19 | }
20 |
21 | @Test
22 | fun `validate should return an error for a string shorter than minLength`() {
23 | val property = StringProperty(minLength = 5)
24 | val scopeKey = "key"
25 | val value = "abc"
26 |
27 | val result = property.validate(scopeKey, value).first()
28 |
29 | assertEquals(
30 | FieldError.MinLengthFieldError(5, scopeKey).message,
31 | result.message,
32 | )
33 | }
34 |
35 | @Test
36 | fun `validate should return an error for a string longer than maxLength`() {
37 | val property = StringProperty(maxLength = 5)
38 | val scopeKey = "key"
39 | val value = "exceeds"
40 |
41 | val result = property.validate(scopeKey, value).first()
42 |
43 | assertEquals(
44 | FieldError.MaxLengthFieldError(5, scopeKey).message,
45 | result.message,
46 | )
47 | }
48 |
49 | @Test
50 | fun `validate should return an error for a string not in enum`() {
51 | val property = StringProperty(enum = persistentListOf("allowed1", "allowed2"))
52 | val scopeKey = "key"
53 | val value = "notAllowed"
54 |
55 | val result = property.validate(scopeKey, value).first()
56 |
57 | assertEquals(
58 | FieldError.InvalidEnumFieldError(listOf("allowed1", "allowed2"), scopeKey).message,
59 | result.message,
60 | )
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/models/schema/PropertyPatternSerializationTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.schema
2 |
3 | import kotlinx.collections.immutable.persistentMapOf
4 | import kotlinx.serialization.json.Json
5 | import kotlin.test.Test
6 | import kotlin.test.assertEquals
7 |
8 | class PropertyPatternSerializationTest {
9 | private val json = Json { encodeDefaults = true }
10 |
11 | @Test
12 | fun `should serialize and deserialize pattern in BooleanProperty`() {
13 | val property = BooleanProperty(pattern = "true|false".toRegex())
14 | val serialized = json.encodeToString(BooleanProperty.serializer(), property)
15 | val deserialized = json.decodeFromString(BooleanProperty.serializer(), serialized)
16 |
17 | assertEquals(property.pattern!!.pattern, deserialized.pattern!!.pattern)
18 | }
19 |
20 | @Test
21 | fun `should serialize and deserialize pattern in StringProperty`() {
22 | val property = StringProperty(pattern = "[a-zA-Z]+".toRegex())
23 | val serialized = json.encodeToString(StringProperty.serializer(), property)
24 | val deserialized = json.decodeFromString(StringProperty.serializer(), serialized)
25 |
26 | assertEquals(property.pattern!!.pattern, deserialized.pattern!!.pattern)
27 | }
28 |
29 | @Test
30 | fun `should serialize and deserialize pattern in ObjectProperty`() {
31 | val property =
32 | ObjectProperty(
33 | properties = persistentMapOf(),
34 | pattern = "\\d+".toRegex(),
35 | )
36 | val serialized = json.encodeToString(ObjectProperty.serializer(), property)
37 | val deserialized = json.decodeFromString(ObjectProperty.serializer(), serialized)
38 |
39 | assertEquals(property.pattern!!.pattern, deserialized.pattern!!.pattern)
40 | }
41 |
42 | @Test
43 | fun `should serialize and deserialize pattern in ArrayProperty`() {
44 | val property = ArrayProperty(pattern = ".*".toRegex())
45 | val serialized = json.encodeToString(ArrayProperty.serializer(), property)
46 | val deserialized = json.decodeFromString(ArrayProperty.serializer(), serialized)
47 |
48 | assertEquals(property.pattern!!.pattern, deserialized.pattern!!.pattern)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/PropertyLabelTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Control
5 | import kotlin.test.Test
6 | import kotlin.test.assertEquals
7 | import kotlin.test.assertNull
8 |
9 | class PropertyLabelTest {
10 | @Test
11 | fun `label should return property title with asterisk when required`() {
12 | val property = StringProperty(title = "My Label")
13 | val control = Control(scope = "#/properties/label")
14 |
15 | val result = property.label(required = true, control = control)
16 |
17 | assertEquals("My Label*", result)
18 | }
19 |
20 | @Test
21 | fun `label should return control label with asterisk when required and property title is null`() {
22 | val property = StringProperty(title = null)
23 | val control = Control(scope = "#/properties/label", label = "Control Label")
24 |
25 | val result = property.label(required = true, control = control)
26 |
27 | assertEquals("Control Label*", result)
28 | }
29 |
30 | @Test
31 | fun `label should return null when both property title and control label are null`() {
32 | val property = StringProperty(title = null)
33 | val control = Control(scope = "#/properties/label", label = null)
34 |
35 | val result = property.label(required = true, control = control)
36 |
37 | assertNull(result)
38 | }
39 |
40 | @Test
41 | fun `label should return property title without asterisk when not required`() {
42 | val property = StringProperty(title = "My Label")
43 | val control = Control(scope = "#/properties/label")
44 |
45 | val result = property.label(required = false, control = control)
46 |
47 | assertEquals("My Label", result)
48 | }
49 |
50 | @Test
51 | fun `label should return control label without asterisk when not required and property title is null`() {
52 | val property = StringProperty(title = null)
53 | val control = Control(scope = "#/properties/label", label = "Control Label")
54 |
55 | val result = property.label(required = false, control = control)
56 |
57 | assertEquals("Control Label", result)
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/ext/JsonPrimitive.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import kotlinx.serialization.json.JsonPrimitive
4 |
5 | /**
6 | * Get any value from [JsonPrimitive] type which convert a string to [Double], [Int], [Float], [Boolean] or [String].
7 | */
8 | val JsonPrimitive.anyValue: Any
9 | get() =
10 | when {
11 | this.content.toDoubleOrNull() != null -> this.content.toDouble()
12 | this.content.toIntOrNull() != null -> this.content.toInt()
13 | this.content.toFloatOrNull() != null -> this.content.toFloat()
14 | this.content.toBooleanStrictOrNull() != null -> this.content.toBoolean()
15 | else -> this.content
16 | }
17 |
18 | /**
19 | * Get value from [JsonPrimitive] type which convert a string to [Double], [Int], [Float], [Boolean] or [String].
20 | */
21 | inline fun JsonPrimitive.value(): T =
22 | when (T::class) {
23 | Double::class ->
24 | this.content.toDoubleOrNull() as? T
25 | ?: throw IllegalArgumentException("Cannot convert to Double")
26 | Int::class ->
27 | this.content.toIntOrNull() as? T
28 | ?: throw IllegalArgumentException("Cannot convert to Int")
29 | Float::class ->
30 | this.content.toFloatOrNull() as? T
31 | ?: throw IllegalArgumentException("Cannot convert to Float")
32 | Boolean::class ->
33 | this.content.toBooleanStrictOrNull() as? T
34 | ?: throw IllegalArgumentException("Cannot convert to Boolean")
35 | String::class -> this.content as T
36 | else -> throw IllegalArgumentException("Unsupported type: ${T::class}")
37 | }
38 |
39 | /**
40 | * Convert a generic value to a [JsonPrimitive] with a [String], [Number] or [Boolean].
41 | * If you try to convert a value with another type, a [NotImplementedError] exception will be thrown.
42 | * @return [JsonPrimitive] value
43 | */
44 | fun Any?.toJsonPrimitive(): JsonPrimitive? {
45 | if (this == null) return null
46 | return when (this) {
47 | is String -> JsonPrimitive(this)
48 | is Number -> JsonPrimitive(this)
49 | is Boolean -> JsonPrimitive(this)
50 | else -> TODO("Is not implemented in JsonPrimitive")
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/renderers/cupertino/src/commonMain/kotlin/com/paligot/jsonforms/cupertino/ui/SegmentedControl.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.cupertino.ui
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.fillMaxWidth
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.graphics.Color
8 | import com.paligot.jsonforms.kotlin.internal.ext.value
9 | import com.paligot.jsonforms.kotlin.models.schema.Property
10 | import com.slapps.cupertino.CupertinoSegmentedControl
11 | import com.slapps.cupertino.CupertinoSegmentedControlTab
12 | import com.slapps.cupertino.CupertinoText
13 | import com.slapps.cupertino.ExperimentalCupertinoApi
14 | import com.slapps.cupertino.theme.CupertinoTheme
15 | import kotlinx.collections.immutable.ImmutableList
16 |
17 | @OptIn(ExperimentalCupertinoApi::class)
18 | @Composable
19 | internal fun SegmentedControl(
20 | value: String?,
21 | values: ImmutableList,
22 | modifier: Modifier = Modifier,
23 | label: String? = null,
24 | description: String? = null,
25 | error: String? = null,
26 | onValueChange: (String) -> Unit,
27 | ) {
28 | Column(modifier = modifier) {
29 | label?.let {
30 | CupertinoText(
31 | text = it,
32 | style = CupertinoTheme.typography.subhead,
33 | )
34 | }
35 | if (error != null) {
36 | CupertinoText(text = error, color = Color.Red)
37 | } else if (description != null) {
38 | CupertinoText(text = description)
39 | }
40 | val selectedTabIndex = values.indexOfFirst { it.const?.value() == value }
41 | CupertinoSegmentedControl(
42 | modifier = Modifier.fillMaxWidth(),
43 | selectedTabIndex = if (selectedTabIndex == -1) 0 else selectedTabIndex,
44 | tabs = {
45 | values.forEach {
46 | CupertinoSegmentedControlTab(
47 | onClick = { onValueChange.invoke(it.const?.value() ?: "") },
48 | isSelected = value == it.const?.value(),
49 | content = {
50 | CupertinoText(text = it.title ?: "")
51 | },
52 | )
53 | }
54 | },
55 | )
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/renderers/cupertino/src/commonMain/kotlin/com/paligot/jsonforms/cupertino/ui/OutlinedTextField.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.cupertino.ui
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.fillMaxWidth
5 | import androidx.compose.foundation.text.KeyboardActions
6 | import androidx.compose.foundation.text.KeyboardOptions
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.focus.FocusDirection
10 | import androidx.compose.ui.platform.LocalFocusManager
11 | import androidx.compose.ui.text.input.VisualTransformation
12 | import com.slapps.cupertino.CupertinoText
13 | import com.slapps.cupertino.CupertinoTextField
14 | import com.slapps.cupertino.theme.CupertinoTheme
15 |
16 | @Composable
17 | internal fun OutlinedTextField(
18 | value: String?,
19 | modifier: Modifier = Modifier,
20 | label: String? = null,
21 | description: String? = null,
22 | enabled: Boolean = true,
23 | error: String? = null,
24 | visualTransformation: VisualTransformation = VisualTransformation.None,
25 | keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
26 | onValueChange: (String) -> Unit,
27 | ) {
28 | val focusManager = LocalFocusManager.current
29 | val supportingText = error ?: description
30 | Column {
31 | CupertinoTextField(
32 | value = value ?: "",
33 | onValueChange = onValueChange,
34 | modifier = modifier.fillMaxWidth(),
35 | enabled = enabled,
36 | isError = error != null,
37 | placeholder =
38 | if (label != null) {
39 | { CupertinoText(text = label) }
40 | } else {
41 | null
42 | },
43 | keyboardOptions = keyboardOptions,
44 | keyboardActions =
45 | KeyboardActions(
46 | onNext = { focusManager.moveFocus(FocusDirection.Down) },
47 | onDone = { focusManager.clearFocus(true) },
48 | ),
49 | maxLines = 1,
50 | singleLine = true,
51 | visualTransformation = visualTransformation,
52 | )
53 | if (supportingText != null) {
54 | CupertinoText(
55 | text = supportingText,
56 | style = CupertinoTheme.typography.caption1,
57 | )
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/checks/NumberPropertyValidateTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.checks
2 |
3 | import com.paligot.jsonforms.kotlin.internal.FieldError
4 | import com.paligot.jsonforms.kotlin.models.schema.NumberProperty
5 | import kotlin.test.Test
6 | import kotlin.test.assertEquals
7 |
8 | class NumberPropertyValidateTest {
9 | @Test
10 | fun `validate should return no errors for a valid number within range`() {
11 | val property = NumberProperty(minimum = 1, maximum = 10)
12 | val scopeKey = "key"
13 | val value = "5.0"
14 |
15 | val result = property.validate(scopeKey, value)
16 |
17 | assertEquals(emptyList(), result)
18 | }
19 |
20 | @Test
21 | fun `validate should return an error for a number below the minimum`() {
22 | val property = NumberProperty(minimum = 1)
23 | val scopeKey = "key"
24 | val value = "0.5"
25 |
26 | val result = property.validate(scopeKey, value).first()
27 |
28 | assertEquals(
29 | FieldError.MinValueFieldError(1, scopeKey).scope,
30 | result.scope,
31 | )
32 | assertEquals(
33 | FieldError.MinValueFieldError(1, scopeKey).message,
34 | result.message,
35 | )
36 | }
37 |
38 | @Test
39 | fun `validate should return an error for a number above the maximum`() {
40 | val property = NumberProperty(maximum = 10)
41 | val scopeKey = "key"
42 | val value = "15.0"
43 |
44 | val result = property.validate(scopeKey, value).first()
45 |
46 | assertEquals(
47 | FieldError.MaxValueFieldError(10, scopeKey).scope,
48 | result.scope,
49 | )
50 | assertEquals(
51 | FieldError.MaxValueFieldError(10, scopeKey).message,
52 | result.message,
53 | )
54 | }
55 |
56 | @Test
57 | fun `validate should return an error for a malformed number`() {
58 | val property = NumberProperty()
59 | val scopeKey = "key"
60 | val value = "invalid"
61 |
62 | val result = property.validate(scopeKey, value).first()
63 |
64 | assertEquals(
65 | FieldError.MalformedFieldError(scopeKey).scope,
66 | result.scope,
67 | )
68 | assertEquals(
69 | FieldError.MalformedFieldError(scopeKey).message,
70 | result.message,
71 | )
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/shared/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
3 |
4 | plugins {
5 | alias(libs.plugins.jetbrains.kotlin.multiplatform)
6 | alias(libs.plugins.android.library)
7 | alias(libs.plugins.jetbrains.dokka)
8 | alias(libs.plugins.jetbrains.kotlin.serialization)
9 | alias(libs.plugins.ktlint)
10 | alias(libs.plugins.vanniktech.maven.publish)
11 | alias(libs.plugins.jetbrains.kotlinx.binary.compatibility.validator)
12 | }
13 |
14 | kotlin {
15 | androidTarget {
16 | compilations.all {
17 | compileTaskProvider.configure {
18 | compilerOptions {
19 | jvmTarget.set(JvmTarget.JVM_21)
20 | }
21 | }
22 | }
23 | }
24 |
25 | jvm("desktop")
26 |
27 | listOf(
28 | iosX64(),
29 | iosArm64(),
30 | iosSimulatorArm64(),
31 | ).forEach {
32 | it.binaries.framework {
33 | baseName = "shared"
34 | isStatic = true
35 | }
36 | }
37 |
38 | sourceSets {
39 | commonMain.dependencies {
40 | api(libs.jetbrains.kotlinx.collections)
41 | implementation(libs.jetbrains.kotlinx.coroutines)
42 | implementation(libs.jetbrains.kotlinx.serialization.json)
43 | }
44 | commonTest.dependencies {
45 | implementation(libs.jetbrains.kotlin.test)
46 | }
47 | }
48 | }
49 |
50 | tasks {
51 | withType {
52 | kotlinOptions {
53 | freeCompilerArgs = freeCompilerArgs + listOf("-opt-in=kotlin.RequiresOptIn")
54 | jvmTarget = JavaVersion.toVersion(JavaVersion.VERSION_21).toString()
55 | }
56 | }
57 |
58 | withType {
59 | val javaToolchains = project.extensions.getByType()
60 | javaCompiler.set(
61 | javaToolchains.compilerFor {
62 | languageVersion.set(JavaLanguageVersion.of(JavaVersion.VERSION_21.toString()))
63 | },
64 | )
65 | }
66 | }
67 |
68 | android {
69 | namespace = "com.paligot.jsonforms.kotlin"
70 | compileSdk = 35
71 | defaultConfig {
72 | minSdk = 26
73 | }
74 | compileOptions {
75 | sourceCompatibility = JavaVersion.VERSION_21
76 | targetCompatibility = JavaVersion.VERSION_21
77 | }
78 | }
79 |
80 | mavenPublishing {
81 | pom {
82 | name.set("core")
83 | description.set("Core data models for JSON Schema, UI Schema, and the data handled by the forms")
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/renderers/material3/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
3 |
4 | plugins {
5 | alias(libs.plugins.jetbrains.kotlin.multiplatform)
6 | alias(libs.plugins.android.library)
7 | alias(libs.plugins.jetbrains.compose)
8 | alias(libs.plugins.jetbrains.compose.compiler)
9 | alias(libs.plugins.jetbrains.dokka)
10 | alias(libs.plugins.ktlint)
11 | alias(libs.plugins.vanniktech.maven.publish)
12 | alias(libs.plugins.jetbrains.kotlinx.binary.compatibility.validator)
13 | }
14 |
15 | kotlin {
16 | androidTarget {
17 | compilations.all {
18 | compileTaskProvider.configure {
19 | compilerOptions {
20 | jvmTarget.set(JvmTarget.JVM_21)
21 | }
22 | }
23 | }
24 | }
25 |
26 | jvm("desktop")
27 |
28 | listOf(
29 | iosX64(),
30 | iosArm64(),
31 | iosSimulatorArm64(),
32 | ).forEach {
33 | it.binaries.framework {
34 | baseName = "material3"
35 | isStatic = true
36 | }
37 | }
38 |
39 | sourceSets {
40 | commonMain.dependencies {
41 | api(projects.ui)
42 | api(projects.shared)
43 | implementation(compose.material3)
44 | implementation(compose.ui)
45 | api(libs.jetbrains.kotlinx.collections)
46 | implementation(libs.jetbrains.kotlinx.coroutines)
47 | implementation(libs.jetbrains.kotlinx.serialization.json)
48 | }
49 | commonTest.dependencies {
50 | implementation(libs.jetbrains.kotlin.test)
51 | }
52 | }
53 | }
54 |
55 | tasks {
56 | withType {
57 | kotlinOptions {
58 | freeCompilerArgs = freeCompilerArgs + listOf("-opt-in=kotlin.RequiresOptIn")
59 | jvmTarget = JavaVersion.toVersion(JavaVersion.VERSION_21).toString()
60 | }
61 | }
62 |
63 | withType {
64 | val javaToolchains = project.extensions.getByType()
65 | javaCompiler.set(
66 | javaToolchains.compilerFor {
67 | languageVersion.set(JavaLanguageVersion.of(JavaVersion.VERSION_21.toString()))
68 | },
69 | )
70 | }
71 | }
72 |
73 | android {
74 | namespace = "com.paligot.jsonforms.material3"
75 | compileSdk = 35
76 | defaultConfig {
77 | minSdk = 26
78 | }
79 | compileOptions {
80 | sourceCompatibility = JavaVersion.VERSION_21
81 | targetCompatibility = JavaVersion.VERSION_21
82 | }
83 | }
84 |
85 | mavenPublishing {
86 | pom {
87 | name.set("material3")
88 | description.set("Implement a Renderer specifically for the Material 3 design system.")
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/models/uischema/UiSchemaTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.models.uischema
2 |
3 | import kotlinx.collections.immutable.persistentListOf
4 | import kotlinx.serialization.json.Json
5 | import kotlin.test.Test
6 | import kotlin.test.assertEquals
7 |
8 | class UiSchemaTest {
9 | private val json = Json { encodeDefaults = true }
10 |
11 | @Test
12 | fun `test VerticalLayout serialization`() {
13 | val verticalLayout =
14 | VerticalLayout(
15 | elements =
16 | persistentListOf(
17 | Control(scope = "name", label = "Name"),
18 | Control(scope = "age", label = "Age"),
19 | ),
20 | )
21 | val serialized = json.encodeToString(UiSchema.serializer(), verticalLayout)
22 | val deserialized = json.decodeFromString(UiSchema.serializer(), serialized)
23 |
24 | assertEquals(verticalLayout, deserialized)
25 | }
26 |
27 | @Test
28 | fun `test HorizontalLayout serialization`() {
29 | val horizontalLayout =
30 | HorizontalLayout(
31 | elements =
32 | persistentListOf(
33 | Control(scope = "email", label = "Email"),
34 | Control(scope = "phone", label = "Phone"),
35 | ),
36 | )
37 | val serialized = json.encodeToString(UiSchema.serializer(), horizontalLayout)
38 | val deserialized = json.decodeFromString(UiSchema.serializer(), serialized)
39 |
40 | assertEquals(horizontalLayout, deserialized)
41 | }
42 |
43 | @Test
44 | fun `test GroupLayout serialization`() {
45 | val groupLayout =
46 | GroupLayout(
47 | label = "User Info",
48 | elements =
49 | persistentListOf(
50 | Control(scope = "username", label = "Username"),
51 | Control(scope = "password", label = "Password"),
52 | ),
53 | )
54 | val serialized = json.encodeToString(UiSchema.serializer(), groupLayout)
55 | val deserialized = json.decodeFromString(UiSchema.serializer(), serialized)
56 |
57 | assertEquals(groupLayout, deserialized)
58 | }
59 |
60 | @Test
61 | fun `test Control serialization`() {
62 | val control =
63 | Control(
64 | scope = "address",
65 | label = "Address",
66 | options = ControlOptions(format = Format.Email, readOnly = true),
67 | )
68 | val serialized = json.encodeToString(UiSchema.serializer(), control)
69 | val deserialized = json.decodeFromString(UiSchema.serializer(), serialized)
70 |
71 | assertEquals(control, deserialized)
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/checks/PropertyValidatePropertyTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.checks
2 |
3 | import com.paligot.jsonforms.kotlin.internal.FieldError
4 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
5 | import kotlinx.serialization.json.JsonPrimitive
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 | import kotlin.test.assertTrue
9 |
10 | class PropertyValidatePropertyTest {
11 | @Test
12 | fun `validateProperty should return no errors when value matches const`() {
13 | val property = StringProperty(const = JsonPrimitive("expectedValue"))
14 | val result = property.validateProperty("fieldId", "expectedValue")
15 | assertTrue(result.isEmpty(), "Expected no errors when value matches const")
16 | }
17 |
18 | @Test
19 | fun `validateProperty should return error when value does not match const`() {
20 | val property = StringProperty(const = JsonPrimitive("expectedValue"))
21 | val result = property.validateProperty("fieldId", "wrongValue")
22 | assertEquals(1, result.size)
23 | assertTrue(result.first() is FieldError.InvalidValueFieldError)
24 | }
25 |
26 | @Test
27 | fun `validateProperty should return no errors when value matches pattern`() {
28 | val property = StringProperty(pattern = "\\d{3}-\\d{2}-\\d{4}".toRegex())
29 | val result = property.validateProperty("fieldId", "123-45-6789")
30 | assertTrue(result.isEmpty(), "Expected no errors when value matches pattern")
31 | }
32 |
33 | @Test
34 | fun `validateProperty should return error when value does not match pattern`() {
35 | val property = StringProperty(pattern = "\\d{3}-\\d{2}-\\d{4}".toRegex())
36 | val result = property.validateProperty("fieldId", "invalid-pattern")
37 | assertEquals(1, result.size)
38 | assertTrue(result.first() is FieldError.PatternFieldError)
39 | }
40 |
41 | @Test
42 | fun `validateProperty should return no errors when value does not match not constraint`() {
43 | val notProperty = StringProperty(const = JsonPrimitive("forbiddenValue"))
44 | val property = StringProperty(not = notProperty)
45 | val result = property.validateProperty("fieldId", "allowedValue")
46 | assertTrue(result.isEmpty(), "Expected no errors when value does not match not constraint")
47 | }
48 |
49 | @Test
50 | fun `validateProperty should return error when value matches not constraint`() {
51 | val notProperty = StringProperty(const = JsonPrimitive("forbiddenValue"))
52 | val property = StringProperty(not = notProperty)
53 | val result = property.validateProperty("fieldId", "forbiddenValue")
54 | assertEquals(1, result.size)
55 | assertTrue(result.first() is FieldError.InvalidNotPropertyError)
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/renderers/cupertino/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
3 |
4 | plugins {
5 | alias(libs.plugins.jetbrains.kotlin.multiplatform)
6 | alias(libs.plugins.android.library)
7 | alias(libs.plugins.jetbrains.compose)
8 | alias(libs.plugins.jetbrains.compose.compiler)
9 | alias(libs.plugins.jetbrains.dokka)
10 | alias(libs.plugins.ktlint)
11 | alias(libs.plugins.vanniktech.maven.publish)
12 | alias(libs.plugins.jetbrains.kotlinx.binary.compatibility.validator)
13 | }
14 |
15 | kotlin {
16 | androidTarget {
17 | compilations.all {
18 | compileTaskProvider.configure {
19 | compilerOptions {
20 | jvmTarget.set(JvmTarget.JVM_21)
21 | }
22 | }
23 | }
24 | }
25 |
26 | jvm("desktop")
27 |
28 | listOf(
29 | iosX64(),
30 | iosArm64(),
31 | iosSimulatorArm64(),
32 | ).forEach {
33 | it.binaries.framework {
34 | baseName = "cupertino"
35 | isStatic = true
36 | }
37 | }
38 |
39 | sourceSets {
40 | commonMain.dependencies {
41 | api(projects.ui)
42 | api(projects.shared)
43 | implementation(compose.ui)
44 | implementation(compose.foundation)
45 | implementation(libs.cupertino)
46 | api(libs.jetbrains.kotlinx.collections)
47 | implementation(libs.jetbrains.kotlinx.coroutines)
48 | implementation(libs.jetbrains.kotlinx.serialization.json)
49 | }
50 | commonTest.dependencies {
51 | implementation(libs.jetbrains.kotlin.test)
52 | }
53 | }
54 | }
55 |
56 | tasks {
57 | withType {
58 | kotlinOptions {
59 | freeCompilerArgs = freeCompilerArgs + listOf("-opt-in=kotlin.RequiresOptIn")
60 | jvmTarget = JavaVersion.toVersion(JavaVersion.VERSION_21).toString()
61 | }
62 | }
63 |
64 | withType {
65 | val javaToolchains = project.extensions.getByType()
66 | javaCompiler.set(
67 | javaToolchains.compilerFor {
68 | languageVersion.set(JavaLanguageVersion.of(JavaVersion.VERSION_21.toString()))
69 | },
70 | )
71 | }
72 | }
73 |
74 | android {
75 | namespace = "com.paligot.jsonforms.cupertino"
76 | compileSdk = 35
77 | defaultConfig {
78 | minSdk = 26
79 | }
80 | compileOptions {
81 | sourceCompatibility = JavaVersion.VERSION_21
82 | targetCompatibility = JavaVersion.VERSION_21
83 | }
84 | }
85 |
86 | mavenPublishing {
87 | pom {
88 | name.set("cupertino")
89 | description.set("Implement a Renderer for the Apple ecosystem, leveraging the compose-cupertino library.")
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/PropertyMaxCounterTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.NumberProperty
4 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
5 | import com.paligot.jsonforms.kotlin.models.uischema.Control
6 | import com.paligot.jsonforms.kotlin.models.uischema.ControlOptions
7 | import kotlin.test.Test
8 | import kotlin.test.assertEquals
9 | import kotlin.test.assertNull
10 |
11 | class PropertyMaxCounterTest {
12 | @Test
13 | fun `getMaxCounter should return null when showMaxCounter is false`() {
14 | val property = StringProperty(maxLength = 10)
15 | val control = Control(scope = "#/properties/string", options = ControlOptions(showMaxCounter = false))
16 |
17 | val result = property.getMaxCounter("value", control)
18 |
19 | assertNull(result)
20 | }
21 |
22 | @Test
23 | fun `getMaxCounter should return null when maxLength is null for StringProperty`() {
24 | val property = StringProperty(maxLength = null)
25 | val control = Control(scope = "#/properties/string", options = ControlOptions(showMaxCounter = true))
26 |
27 | val result = property.getMaxCounter("value", control)
28 |
29 | assertNull(result)
30 | }
31 |
32 | @Test
33 | fun `getMaxCounter should return correct Pair for StringProperty`() {
34 | val property = StringProperty(maxLength = 10)
35 | val control = Control(scope = "#/properties/string", options = ControlOptions(showMaxCounter = true))
36 |
37 | val result = property.getMaxCounter("value", control)
38 |
39 | assertEquals(Pair(5, 10), result)
40 | }
41 |
42 | @Test
43 | fun `getMaxCounter should return null when maximum is null for NumberProperty`() {
44 | val property = NumberProperty(maximum = null)
45 | val control = Control(scope = "#/properties/number", options = ControlOptions(showMaxCounter = true))
46 |
47 | val result = property.getMaxCounter("123", control)
48 |
49 | assertNull(result)
50 | }
51 |
52 | @Test
53 | fun `getMaxCounter should return correct Pair for NumberProperty`() {
54 | val property = NumberProperty(maximum = 100)
55 | val control = Control(scope = "#/properties/number", options = ControlOptions(showMaxCounter = true))
56 |
57 | val result = property.getMaxCounter("47", control)
58 |
59 | assertEquals(Pair(47, 100), result)
60 | }
61 |
62 | @Test
63 | fun `getMaxCounter should handle empty string for NumberProperty`() {
64 | val property = NumberProperty(maximum = 100)
65 | val control = Control(scope = "#/properties/number", options = ControlOptions(showMaxCounter = true))
66 |
67 | val result = property.getMaxCounter("", control)
68 |
69 | assertEquals(Pair(0, 100), result)
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/checks/ObjectPropertyIsRequiredTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.checks
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.ObjectProperty
4 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
5 | import com.paligot.jsonforms.kotlin.models.uischema.Control
6 | import kotlinx.collections.immutable.persistentListOf
7 | import kotlinx.collections.immutable.persistentMapOf
8 | import kotlin.test.Test
9 | import kotlin.test.assertFails
10 | import kotlin.test.assertFalse
11 | import kotlin.test.assertTrue
12 |
13 | class ObjectPropertyIsRequiredTest {
14 | @Test
15 | fun `propertyIsRequired should return true when the property is in the required list`() {
16 | val schema =
17 | ObjectProperty(
18 | properties = persistentMapOf("key" to StringProperty()),
19 | required = persistentListOf("key"),
20 | )
21 | val control = Control(scope = "#/properties/key")
22 | val data = mapOf()
23 |
24 | val result = schema.propertyIsRequired(control, data)
25 |
26 | assertTrue(result)
27 | }
28 |
29 | @Test
30 | fun `propertyIsRequired should return true when the property is required by anyOf`() {
31 | val schema =
32 | ObjectProperty(
33 | properties = persistentMapOf("key" to StringProperty()),
34 | anyOf =
35 | persistentListOf(
36 | ObjectProperty(
37 | properties = persistentMapOf(),
38 | required = persistentListOf("key"),
39 | ),
40 | ),
41 | )
42 | val control = Control(scope = "#/properties/key")
43 | val data = mapOf("key" to "value")
44 |
45 | val result = schema.propertyIsRequired(control, data)
46 |
47 | assertTrue(result)
48 | }
49 |
50 | @Test
51 | fun `propertyIsRequired should return false when the property is not required`() {
52 | val schema =
53 | ObjectProperty(
54 | properties = persistentMapOf("key" to StringProperty()),
55 | )
56 | val control = Control(scope = "#/properties/key")
57 | val data = mapOf()
58 |
59 | val result = schema.propertyIsRequired(control, data)
60 |
61 | assertFalse(result)
62 | }
63 |
64 | @Test
65 | fun `propertyIsRequired should return false when the property does not exist`() {
66 | val schema =
67 | ObjectProperty(
68 | properties = persistentMapOf("key" to StringProperty()),
69 | )
70 | val control = Control(scope = "#/properties/nonexistent")
71 | val data = mapOf()
72 |
73 | assertFails { schema.propertyIsRequired(control, data) }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | pull_request:
5 | concurrency:
6 | group: build-${{ github.ref }}
7 | cancel-in-progress: true
8 |
9 | jobs:
10 | composeApp:
11 | runs-on: macos-14
12 | timeout-minutes: 60
13 | steps:
14 | - uses: actions/checkout@v4
15 |
16 | - uses: actions/setup-java@v4
17 | with:
18 | distribution: temurin
19 | java-version: 21
20 |
21 | - uses: gradle/actions/setup-gradle@v4
22 | with:
23 | gradle-version: wrapper
24 | build-scan-publish: true
25 | build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
26 | build-scan-terms-of-use-agree: "yes"
27 |
28 | - name: Copy CI gradle.properties
29 | run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
30 |
31 | - name: Assemble project
32 | run: ./gradlew :composeApp:assemble
33 |
34 | - name: Upload build outputs
35 | if: always()
36 | uses: actions/upload-artifact@v4
37 | with:
38 | name: build-outputs
39 | path: composeApp/build/outputs
40 | linter:
41 | runs-on: ubuntu-latest
42 | timeout-minutes: 60
43 | steps:
44 | - uses: actions/checkout@v4
45 |
46 | - uses: actions/setup-java@v4
47 | with:
48 | distribution: temurin
49 | java-version: 21
50 |
51 | - uses: gradle/actions/setup-gradle@v4
52 | with:
53 | gradle-version: wrapper
54 | build-scan-publish: true
55 | build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
56 | build-scan-terms-of-use-agree: "yes"
57 |
58 | - name: Copy CI gradle.properties
59 | run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
60 |
61 | - name: Check Kotlin lint
62 | run: ./gradlew ktlintCheck
63 |
64 | - name: Check api
65 | run: ./gradlew apiCheck
66 | test:
67 | runs-on: ubuntu-latest
68 | timeout-minutes: 60
69 | steps:
70 | - uses: actions/checkout@v4
71 |
72 | - uses: actions/setup-java@v4
73 | with:
74 | distribution: temurin
75 | java-version: 21
76 |
77 | - uses: gradle/actions/setup-gradle@v4
78 | with:
79 | gradle-version: wrapper
80 | build-scan-publish: true
81 | build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
82 | build-scan-terms-of-use-agree: "yes"
83 |
84 | - name: Copy CI gradle.properties
85 | run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
86 |
87 | - name: Run desktop tests
88 | run: ./gradlew desktopTest
89 |
90 | - name: Upload reports
91 | if: always()
92 | uses: actions/upload-artifact@v4
93 | with:
94 | name: reports
95 | path: |
96 | **/build/reports/*
97 |
--------------------------------------------------------------------------------
/docs/state-management.md:
--------------------------------------------------------------------------------
1 | State management is central to building dynamic forms with `jsonforms-kotlin`. The library provides
2 | composable functions to create and remember form state, as well as a rich API to interact with and
3 | observe form data, validation, and errors.
4 |
5 | ## Creating and remembering form state
6 |
7 | There are two main functions to create and remember form state:
8 |
9 | `rememberJsonFormState(initialValues: MutableMap)`
10 |
11 | Creates a `JsonFormState` instance that persists across recompositions. The `initialValues` map
12 | sets the starting values for each field.
13 |
14 | `rememberJsonFormState(initialValues: MutableMap, vararg keys: String)`
15 |
16 | Same as above, but the state will reset if any of the provided keys change. Useful for dynamic
17 | forms or when you want to reset the form on certain changes.
18 |
19 | Both functions use Compose's `rememberSaveable` for state persistence.
20 |
21 | **Example:**
22 | ```kotlin
23 | val formState = rememberJsonFormState(mutableMapOf("email" to "", "password" to ""))
24 | // or with reset keys:
25 | val formState = rememberJsonFormState(mutableMapOf(), "email", "password")
26 | ```
27 |
28 | ## JsonFormState interface: usage and examples
29 |
30 | The `JsonFormState` interface provides a set of functions to manage and observe your form's data,
31 | validation, and errors. Here are the main functions, with usage examples and a short description
32 | for each:
33 |
34 | **Initialize state**
35 |
36 | ```kotlin
37 | val formState = rememberJsonFormState(mutableMapOf("email" to "", "password" to ""))
38 | ```
39 |
40 | **Set a field value**
41 |
42 | Set the value of a field:
43 | ```kotlin
44 | formState["email"] = "user@example.com"
45 | ```
46 |
47 | **Get a field value**
48 |
49 | Get the value of a specific field:
50 | ```kotlin
51 | val email = formState.getValue("email")
52 | ```
53 |
54 | **Observe field value**
55 |
56 | Observe a field's value as Compose state (reactively updates UI):
57 | ```kotlin
58 | val emailState: State = formState["email"]
59 | ```
60 |
61 | **Observe field error**
62 |
63 | Observe a field's error state as Compose state:
64 | ```kotlin
65 | val emailError: State = formState.error("email")
66 | ```
67 |
68 | **Validate form**
69 |
70 | Validate the form against your schema and uischema. Returns `true` if valid, `false` otherwise.
71 | Updates error states for each field:
72 | ```kotlin
73 | val isValid = formState.validate(schema, uiSchema)
74 | ```
75 |
76 | **Get all data**
77 |
78 | Get a snapshot of all form data as a map:
79 | ```kotlin
80 | val data = formState.getData()
81 | ```
82 |
83 | **Mark custom errors**
84 |
85 | Manually mark fields as having errors (useful for custom validation scenarios):
86 | ```kotlin
87 | formState.markAsErrors(listOf(FieldError(scope = "#/properties/email", message = "Invalid email")))
88 | ```
89 |
90 | For more details, see the [API reference](api/index.html) or the [usage guide](usage.md).
91 |
--------------------------------------------------------------------------------
/ui/src/desktopTest/kotlin/com/paligot/jsonforms/ui/RendererNumberScopeTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.ui
2 |
3 | import androidx.compose.ui.text.input.ImeAction
4 | import androidx.compose.ui.text.input.KeyboardCapitalization
5 | import androidx.compose.ui.text.input.KeyboardType
6 | import com.paligot.jsonforms.kotlin.SchemaProvider
7 | import com.paligot.jsonforms.kotlin.models.schema.NumberProperty
8 | import com.paligot.jsonforms.kotlin.models.uischema.Control
9 | import io.mockk.every
10 | import io.mockk.mockk
11 | import org.junit.Test
12 | import kotlin.test.assertEquals
13 | import kotlin.test.assertTrue
14 |
15 | class RendererNumberScopeTest {
16 | @Test
17 | fun `label() returns the correct label`() {
18 | val schemaProvider = mockk()
19 | val jsonFormState = mockk()
20 | val control =
21 | Control(
22 | scope = "#/properties/number",
23 | label = "My label",
24 | )
25 | val property = NumberProperty()
26 |
27 | every { schemaProvider.propertyIsRequired(control, any()) } returns true
28 | every { jsonFormState.getData() } returns mapOf()
29 |
30 | val scope = RendererNumberScopeInstance(control, schemaProvider, jsonFormState, property)
31 |
32 | assertEquals("My label*", scope.label())
33 | }
34 |
35 | @Test
36 | fun `description() returns the description`() {
37 | val control = mockk()
38 | val schemaProvider = mockk()
39 | val jsonFormState = mockk()
40 | val property = NumberProperty(description = "Test description")
41 |
42 | val scope = RendererNumberScopeInstance(control, schemaProvider, jsonFormState, property)
43 |
44 | assertEquals("Test description", scope.description())
45 | }
46 |
47 | @Test
48 | fun `enabled() returns true if the field is enabled`() {
49 | val schemaProvider = mockk()
50 | val jsonFormState = mockk()
51 | val control = Control(scope = "#/properties/number")
52 | val property = NumberProperty()
53 |
54 | every { jsonFormState.getData() } returns mapOf()
55 |
56 | val scope = RendererNumberScopeInstance(control, schemaProvider, jsonFormState, property)
57 |
58 | assertTrue(scope.enabled())
59 | }
60 |
61 | @Test
62 | fun `keyboardOptions() returns the correct options`() {
63 | val schemaProvider = mockk()
64 | val jsonFormState = mockk()
65 | val control = Control(scope = "#/properties/number")
66 | val property = NumberProperty()
67 |
68 | val options =
69 | RendererNumberScopeInstance(control, schemaProvider, jsonFormState, property)
70 | .keyboardOptions(
71 | capitalization = KeyboardCapitalization.None,
72 | imeAction = ImeAction.Done,
73 | )
74 |
75 | assertEquals(KeyboardCapitalization.None, options.capitalization)
76 | assertEquals(KeyboardType.Number, options.keyboardType)
77 | assertEquals(ImeAction.Done, options.imeAction)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/ui/src/commonMain/kotlin/com/paligot/jsonforms/ui/JsonForm.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.ui
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.ui.Modifier
7 | import com.paligot.jsonforms.kotlin.SchemaProvider
8 | import com.paligot.jsonforms.kotlin.SchemaProviderImpl
9 | import com.paligot.jsonforms.kotlin.models.schema.Schema
10 | import com.paligot.jsonforms.kotlin.models.uischema.UiSchema
11 |
12 | /**
13 | * Display a form described in a [Schema] and [UiSchema].
14 | * If you want to interact with the form, you can declare your own state before the declaration of this component
15 | * and pass it as parameter. You'll be able to interact with fields of the form.
16 | * If you want to override a specific field of your form, you can add a custom render in the sniper parameter.
17 | *
18 | * ```kotlin
19 | * val schema = Schema(
20 | * properties = mutableMapOf("email" to StringProperty()),
21 | * required = arrayListOf("email")
22 | * )
23 | * val uiSchema = vertical {
24 | * control("#/properties/email") {
25 | * label = "Email"
26 | * }
27 | * }
28 | * val jsonFormsState = rememberJsonFormState(initialValues = mutableMapOf())
29 | * JsonForm(
30 | * schema = schema,
31 | * uiSchema = uiSchema,
32 | * state = jsonFormsState
33 | * )
34 | * ```
35 | *
36 | * @param schema Properties which can be shown on the screen.
37 | * @param uiSchema Form UI description of fields declared in [Schema].
38 | * @param modifier The [Modifier] applied at the root level of the form.
39 | * @param state State of the form to interact with fields inside.
40 | */
41 | @Composable
42 | fun JsonForm(
43 | schema: Schema,
44 | uiSchema: UiSchema,
45 | modifier: Modifier = Modifier,
46 | state: JsonFormState = rememberJsonFormState(initialValues = mutableMapOf()),
47 | layoutContent: @Composable (RendererLayoutScope.(@Composable (UiSchema) -> Unit) -> Unit),
48 | stringContent: @Composable (RendererStringScope.(id: String) -> Unit),
49 | numberContent: @Composable (RendererNumberScope.(id: String) -> Unit),
50 | booleanContent: @Composable (RendererBooleanScope.(id: String) -> Unit),
51 | ) {
52 | val schemeProvider = rememberSchemeProvider(uiSchema = uiSchema, schema = schema)
53 | Box(modifier = modifier) {
54 | Layout(
55 | uiSchema = uiSchema,
56 | jsonFormState = state,
57 | layoutContent = layoutContent,
58 | content = { control ->
59 | Property(
60 | control = control,
61 | schemaProvider = schemeProvider,
62 | jsonFormState = state,
63 | stringContent = stringContent,
64 | numberContent = numberContent,
65 | booleanContent = booleanContent,
66 | )
67 | },
68 | )
69 | }
70 | }
71 |
72 | @Composable
73 | internal fun rememberSchemeProvider(
74 | uiSchema: UiSchema,
75 | schema: Schema,
76 | ): SchemaProvider =
77 | remember(uiSchema, schema) {
78 | SchemaProviderImpl(uiSchema, schema)
79 | }
80 |
--------------------------------------------------------------------------------
/ui/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
3 |
4 | plugins {
5 | alias(libs.plugins.jetbrains.kotlin.multiplatform)
6 | alias(libs.plugins.android.library)
7 | alias(libs.plugins.jetbrains.compose)
8 | alias(libs.plugins.jetbrains.compose.compiler)
9 | alias(libs.plugins.jetbrains.dokka)
10 | alias(libs.plugins.jetbrains.kotlin.serialization)
11 | alias(libs.plugins.ktlint)
12 | alias(libs.plugins.vanniktech.maven.publish)
13 | alias(libs.plugins.jetbrains.kotlinx.binary.compatibility.validator)
14 | }
15 |
16 | kotlin {
17 | androidTarget {
18 | compilations.all {
19 | compileTaskProvider.configure {
20 | compilerOptions {
21 | jvmTarget.set(JvmTarget.JVM_21)
22 | }
23 | }
24 | }
25 | }
26 |
27 | jvm("desktop")
28 |
29 | listOf(
30 | iosX64(),
31 | iosArm64(),
32 | iosSimulatorArm64(),
33 | ).forEach {
34 | it.binaries.framework {
35 | baseName = "ui"
36 | isStatic = true
37 | }
38 | }
39 |
40 | sourceSets {
41 | commonMain.dependencies {
42 | implementation(projects.shared)
43 | implementation(compose.foundation)
44 | implementation(compose.animation)
45 | implementation(compose.ui)
46 | api(libs.jetbrains.kotlinx.collections)
47 | implementation(libs.jetbrains.kotlinx.coroutines)
48 | implementation(libs.jetbrains.kotlinx.serialization.json)
49 | }
50 | // Can't use commonTest because mockk can't be use in native
51 | // FIXME https://github.com/mockk/mockk/issues/950
52 | val desktopTest by getting {
53 | dependencies {
54 | implementation(compose.desktop.uiTestJUnit4)
55 | implementation(compose.desktop.currentOs)
56 | implementation(libs.jetbrains.kotlin.test)
57 | implementation(libs.io.mockk)
58 | }
59 | }
60 | }
61 | }
62 |
63 | tasks {
64 | withType {
65 | kotlinOptions {
66 | freeCompilerArgs = freeCompilerArgs + listOf("-opt-in=kotlin.RequiresOptIn")
67 | jvmTarget = JavaVersion.toVersion(JavaVersion.VERSION_21).toString()
68 | }
69 | }
70 |
71 | withType {
72 | val javaToolchains = project.extensions.getByType()
73 | javaCompiler.set(
74 | javaToolchains.compilerFor {
75 | languageVersion.set(JavaLanguageVersion.of(JavaVersion.VERSION_21.toString()))
76 | },
77 | )
78 | }
79 | }
80 |
81 | android {
82 | namespace = "com.paligot.jsonforms.ui"
83 | compileSdk = 35
84 | defaultConfig {
85 | minSdk = 26
86 | }
87 | compileOptions {
88 | sourceCompatibility = JavaVersion.VERSION_21
89 | targetCompatibility = JavaVersion.VERSION_21
90 | }
91 | }
92 |
93 | mavenPublishing {
94 | pom {
95 | name.set("ui")
96 | description.set("JsonForm composable and defines the Renderer interface.")
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/ext/StringProperty.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Control
5 | import com.paligot.jsonforms.kotlin.models.uischema.Format
6 |
7 | /**
8 | * Check if [StringProperty] is a radio based on the format and enum or oneOf fields.
9 | *
10 | * ```kotlin
11 | * val property = StringProperty(enum = arrayListOf(""))
12 | * val control = Control(
13 | * scope = "#/properties/key",
14 | * options = Options(format = Format.Radio)
15 | * )
16 | * val isRadio = property.isRadio(control)
17 | * ```
18 | *
19 | * @param control Field contained in the [com.paligot.jsonforms.kotlin.models.uischema.UiSchema]
20 | * @return true if the [StringProperty] is a radio
21 | */
22 | fun StringProperty.isRadio(control: Control) =
23 | (control.options?.format == Format.Radio && enum != null && enum.size != 0) ||
24 | (control.options?.format == Format.Radio && oneOf != null && oneOf.size != 0)
25 |
26 | /**
27 | * Check if [StringProperty] is a password based on the format.
28 | *
29 | * ```kotlin
30 | * val property = StringProperty()
31 | * val control = Control(
32 | * scope = "#/properties/key",
33 | * options = Options(format = Format.Password)
34 | * )
35 | * val isPassword = property.isPassword(control)
36 | * ```
37 | *
38 | * @param control Field contained in the [com.paligot.jsonforms.kotlin.models.uischema.UiSchema]
39 | * @return true if the [StringProperty] is a password
40 | */
41 | fun StringProperty.isPassword(control: Control) = control.options?.format == Format.Password
42 |
43 | /**
44 | * Check if [StringProperty] is an email based on the format.
45 | *
46 | * ```kotlin
47 | * val property = StringProperty()
48 | * val control = Control(
49 | * scope = "#/properties/key",
50 | * options = Options(format = Format.Email)
51 | * )
52 | * val isPassword = property.isEmail(control)
53 | * ```
54 | *
55 | * @param control Field contained in the [com.paligot.jsonforms.kotlin.models.uischema.UiSchema]
56 | * @return true if the [StringProperty] is an email
57 | */
58 | fun StringProperty.isEmail(control: Control) = control.options?.format == Format.Email
59 |
60 | /**
61 | * Check if [StringProperty] is a phone based on the format.
62 | *
63 | * ```kotlin
64 | * val property = StringProperty()
65 | * val control = Control(
66 | * scope = "#/properties/key",
67 | * options = Options(format = Format.Phone)
68 | * )
69 | * val isPassword = property.isPhone(control)
70 | * ```
71 | *
72 | * @param control Field contained in the [com.paligot.jsonforms.kotlin.models.uischema.UiSchema]
73 | * @return true if the [StringProperty] is a phone
74 | */
75 | fun StringProperty.isPhone(control: Control) = control.options?.format == Format.Phone
76 |
77 | /**
78 | * Check if [StringProperty] is a dropdown based on the oneOf field.
79 | *
80 | * ```kotlin
81 | * val property = StringProperty(
82 | * oneOf = arrayListOf(PropertyValue(const = "", title = ""))
83 | * )
84 | * val isDropdown = property.isDropdown()
85 | * ```
86 | *
87 | * @return true if the [StringProperty] is a dropdown
88 | */
89 | fun StringProperty.isDropdown() = oneOf != null && oneOf.size != 0
90 |
--------------------------------------------------------------------------------
/ui/src/desktopTest/kotlin/com/paligot/jsonforms/ui/PropertyTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.ui
2 |
3 | import androidx.compose.ui.test.junit4.createComposeRule
4 | import com.paligot.jsonforms.kotlin.SchemaProvider
5 | import com.paligot.jsonforms.kotlin.models.schema.BooleanProperty
6 | import com.paligot.jsonforms.kotlin.models.schema.NumberProperty
7 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
8 | import com.paligot.jsonforms.kotlin.models.uischema.Control
9 | import io.mockk.every
10 | import io.mockk.mockk
11 | import org.junit.Rule
12 | import org.junit.Test
13 |
14 | class PropertyTest {
15 | @get:Rule
16 | val composeTestRule = createComposeRule()
17 |
18 | @Test
19 | fun `renders StringProperty with correct scope`() {
20 | val control = Control(scope = "#/properties/string")
21 | val schemaProvider = mockk()
22 | val jsonFormState = mockk()
23 | val property = StringProperty()
24 |
25 | every { schemaProvider.getPropertyByControl(control) } returns property
26 |
27 | composeTestRule.setContent {
28 | Property(
29 | control = control,
30 | schemaProvider = schemaProvider,
31 | jsonFormState = jsonFormState,
32 | stringContent = { id -> assert(id == "string") },
33 | numberContent = { error("Should not render NumberProperty") },
34 | booleanContent = { error("Should not render BooleanProperty") },
35 | )
36 | }
37 | }
38 |
39 | @Test
40 | fun `renders BooleanProperty with correct scope`() {
41 | val control = Control(scope = "#/properties/boolean")
42 | val schemaProvider = mockk()
43 | val jsonFormState = mockk()
44 | val property = BooleanProperty()
45 |
46 | every { schemaProvider.getPropertyByControl(control) } returns property
47 |
48 | composeTestRule.setContent {
49 | Property(
50 | control = control,
51 | schemaProvider = schemaProvider,
52 | jsonFormState = jsonFormState,
53 | stringContent = { error("Should not render StringProperty") },
54 | numberContent = { error("Should not render NumberProperty") },
55 | booleanContent = { id -> assert(id == "boolean") },
56 | )
57 | }
58 | }
59 |
60 | @Test
61 | fun `renders NumberProperty with correct scope`() {
62 | val control = Control(scope = "#/properties/number")
63 | val schemaProvider = mockk()
64 | val jsonFormState = mockk()
65 | val property = NumberProperty()
66 |
67 | every { schemaProvider.getPropertyByControl(control) } returns property
68 |
69 | composeTestRule.setContent {
70 | Property(
71 | control = control,
72 | schemaProvider = schemaProvider,
73 | jsonFormState = jsonFormState,
74 | stringContent = { error("Should not render StringProperty") },
75 | numberContent = { id -> assert(id == "number") },
76 | booleanContent = { error("Should not render BooleanProperty") },
77 | )
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/queries/ObjectPropertyGetPropertyByControlTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.queries
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.ObjectProperty
4 | import com.paligot.jsonforms.kotlin.models.schema.Schema
5 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
6 | import com.paligot.jsonforms.kotlin.models.uischema.Control
7 | import kotlinx.collections.immutable.persistentMapOf
8 | import kotlin.test.Test
9 | import kotlin.test.assertEquals
10 | import kotlin.test.assertFailsWith
11 |
12 | class ObjectPropertyGetPropertyByControlTest {
13 | @Test
14 | fun `getPropertyByControl should return the correct property when the path is valid`() {
15 | val schema =
16 | Schema(
17 | properties = persistentMapOf("key" to StringProperty()),
18 | )
19 | val control = Control(scope = "#/properties/key")
20 |
21 | val result = schema.getPropertyByControl(control)
22 |
23 | assertEquals(StringProperty::class, result::class)
24 | }
25 |
26 | @Test
27 | fun `getPropertyByControl should throw an error when the property does not exist`() {
28 | val schema =
29 | Schema(
30 | properties = persistentMapOf("key" to StringProperty()),
31 | )
32 | val control = Control(scope = "#/properties/nonexistent")
33 |
34 | assertFailsWith {
35 | schema.getPropertyByControl(control)
36 | }
37 | }
38 |
39 | @Test
40 | fun `getPropertyByControl should throw an error when the last key is an object`() {
41 | val schema =
42 | Schema(
43 | properties =
44 | persistentMapOf(
45 | "key" to ObjectProperty(properties = persistentMapOf("subKey" to StringProperty())),
46 | ),
47 | )
48 | val control = Control(scope = "#/properties/key")
49 |
50 | assertFailsWith {
51 | schema.getPropertyByControl(control)
52 | }
53 | }
54 |
55 | @Test
56 | fun `getPropertyByControl should throw an error when an intermediate key is not an object`() {
57 | val schema =
58 | Schema(
59 | properties = persistentMapOf("key" to StringProperty()),
60 | )
61 | val control = Control(scope = "#/properties/key/properties/subKey")
62 |
63 | assertFailsWith {
64 | schema.getPropertyByControl(control)
65 | }
66 | }
67 |
68 | @Test
69 | fun `getPropertyByControl should return the correct nested property`() {
70 | val schema =
71 | Schema(
72 | properties =
73 | persistentMapOf(
74 | "key" to
75 | ObjectProperty(
76 | properties = persistentMapOf("subKey" to StringProperty()),
77 | ),
78 | ),
79 | )
80 | val control = Control(scope = "#/properties/key/properties/subKey")
81 |
82 | val result = schema.getPropertyByControl(control)
83 |
84 | assertEquals(StringProperty::class, result::class)
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/StringPropertyIsRadioTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Control
5 | import com.paligot.jsonforms.kotlin.models.uischema.ControlOptions
6 | import com.paligot.jsonforms.kotlin.models.uischema.Format
7 | import kotlinx.collections.immutable.persistentListOf
8 | import kotlinx.serialization.json.JsonPrimitive
9 | import kotlin.test.Test
10 | import kotlin.test.assertFalse
11 | import kotlin.test.assertTrue
12 |
13 | class StringPropertyIsRadioTest {
14 | @Test
15 | fun `isRadio should return true when format is Radio and enum is not null or empty`() {
16 | val property = StringProperty(enum = persistentListOf("option1", "option2"))
17 | val control =
18 | Control(
19 | scope = "#/properties/key",
20 | options = ControlOptions(format = Format.Radio),
21 | )
22 |
23 | val result = property.isRadio(control)
24 |
25 | assertTrue(result)
26 | }
27 |
28 | @Test
29 | fun `isRadio should return true when format is Radio and oneOf is not null or empty`() {
30 | val property =
31 | StringProperty(
32 | oneOf =
33 | persistentListOf(
34 | StringProperty(const = JsonPrimitive("value1"), title = "Title1"),
35 | ),
36 | )
37 | val control =
38 | Control(
39 | scope = "#/properties/key",
40 | options = ControlOptions(format = Format.Radio),
41 | )
42 |
43 | val result = property.isRadio(control)
44 |
45 | assertTrue(result)
46 | }
47 |
48 | @Test
49 | fun `isRadio should return false when format is not Radio`() {
50 | val property = StringProperty(enum = persistentListOf("option1", "option2"))
51 | val control = Control(scope = "#/properties/key")
52 |
53 | val result = property.isRadio(control)
54 |
55 | assertFalse(result)
56 | }
57 |
58 | @Test
59 | fun `isRadio should return false when enum and oneOf are both null`() {
60 | val property = StringProperty()
61 | val control =
62 | Control(
63 | scope = "#/properties/key",
64 | options = ControlOptions(format = Format.Radio),
65 | )
66 |
67 | val result = property.isRadio(control)
68 |
69 | assertFalse(result)
70 | }
71 |
72 | @Test
73 | fun `isRadio should return false when enum and oneOf are empty`() {
74 | val property = StringProperty(enum = persistentListOf(), oneOf = persistentListOf())
75 | val control =
76 | Control(
77 | scope = "#/properties/key",
78 | options = ControlOptions(format = Format.Radio),
79 | )
80 |
81 | val result = property.isRadio(control)
82 |
83 | assertFalse(result)
84 | }
85 |
86 | @Test
87 | fun `isRadio should return false when control options are null`() {
88 | val property = StringProperty(enum = persistentListOf("option1"))
89 | val control = Control(scope = "#/properties/key", options = null)
90 |
91 | val result = property.isRadio(control)
92 |
93 | assertFalse(result)
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/queries/Control.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.queries
2 |
3 | import com.paligot.jsonforms.kotlin.internal.ext.isDropdown
4 | import com.paligot.jsonforms.kotlin.models.schema.Property
5 | import com.paligot.jsonforms.kotlin.models.schema.Schema
6 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
7 | import com.paligot.jsonforms.kotlin.models.uischema.Control
8 | import com.paligot.jsonforms.kotlin.models.uischema.UiSchema
9 |
10 | /**
11 | * Check if the [Control] is the last field declared in the [UiSchema].
12 | *
13 | * ```kotlin
14 | * val first = Control(scope = "#/properties/key")
15 | * val last = Control(scope = "#/properties/key2")
16 | * val uiSchema = VerticalLayout(elements = mutableListOf(first, last))
17 | * val schema = Schema(
18 | * properties = mutableMapOf(
19 | * "key" to StringProperty(),
20 | * "key2" to StringProperty()
21 | * )
22 | * )
23 | * val isLastProperty = first.isLastField(uiSchema, schema) // false
24 | * val isLastProperty = last.isLastField(uiSchema, schema) // true
25 | * ```
26 | *
27 | * @param uiSchema Form UI description of fields declared in [Schema].
28 | * @param schema Properties which can be shown on the screen.
29 | * @return true if the control is the last field
30 | */
31 | internal fun Control.isLastField(
32 | uiSchema: UiSchema,
33 | schema: Schema,
34 | ): Boolean = lastField(this, uiSchema, schema)
35 |
36 | private fun lastField(
37 | control: Control,
38 | parent: UiSchema,
39 | schema: Schema,
40 | ): Boolean {
41 | val sizeElements = parent.elements?.size ?: 0
42 | if (sizeElements == 0) {
43 | return false
44 | }
45 | val lastIndex =
46 | parent.elements?.indexOfLast { uiSchema ->
47 | when (uiSchema) {
48 | is Control -> uiSchema.scope == control.scope
49 | else -> false
50 | }
51 | }
52 | // If the control isn't in the list, we inspect the last element to check if it contains sub fields
53 | if (lastIndex == null || lastIndex == -1) {
54 | val lastElement = parent.elements?.last()
55 | return if (lastElement != null) {
56 | lastField(control, lastElement, schema)
57 | } else {
58 | false
59 | }
60 | }
61 | // If the control is the last element, we found it
62 | if (lastIndex == sizeElements - 1) {
63 | return true
64 | }
65 | // Otherwise, we check if elements between our control and the last one contains a string
66 | // element. It is necessary to check because we only need a keyboard for string properties.
67 | // So, if they aren't any string properties between our control and the last one, we can
68 | // consider that our current control is the last field which requires a keyboard.
69 | for (i in (lastIndex + 1) until sizeElements) {
70 | val element = parent.elements?.get(i) ?: error("Element $i not found")
71 | if (element !is Control) {
72 | continue
73 | }
74 | val property = schema.getPropertyByControl(element)
75 | if (property is StringProperty) {
76 | val readOnly = property.readOnly ?: false
77 | val isDropdown = property.isDropdown()
78 | if (readOnly || isDropdown) continue
79 | return false
80 | }
81 | }
82 | return true
83 | }
84 |
--------------------------------------------------------------------------------
/composeApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat
2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
3 |
4 | plugins {
5 | alias(libs.plugins.android.application)
6 | alias(libs.plugins.jetbrains.kotlin.multiplatform)
7 | alias(libs.plugins.jetbrains.kotlin.serialization)
8 | alias(libs.plugins.jetbrains.compose)
9 | alias(libs.plugins.jetbrains.compose.compiler)
10 | }
11 |
12 | android {
13 | namespace = "com.paligot.jsonforms.kotlin.android"
14 | compileSdk = 35
15 | defaultConfig {
16 | applicationId = "com.paligot.jsonforms.kotlin.android"
17 | minSdk = 26
18 | targetSdk = 35
19 | versionCode = 1
20 | versionName = "1.0"
21 | }
22 | packaging {
23 | resources {
24 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
25 | }
26 | }
27 | buildTypes {
28 | getByName("release") {
29 | isMinifyEnabled = false
30 | }
31 | }
32 | compileOptions {
33 | sourceCompatibility = JavaVersion.VERSION_21
34 | targetCompatibility = JavaVersion.VERSION_21
35 | }
36 | }
37 |
38 | compose.desktop {
39 | application {
40 | mainClass = "com.paligot.jsonforms.kotlin.desktop.MainKt"
41 |
42 | nativeDistributions {
43 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
44 | packageName = "com.paligot.jsonforms.kotlin.desktop"
45 | packageVersion = "1.0.0"
46 | }
47 | }
48 | }
49 |
50 | kotlin {
51 | androidTarget()
52 | jvm("desktop")
53 |
54 | sourceSets {
55 | val desktopMain by getting
56 | commonMain.dependencies {
57 | implementation(projects.renderers.material3)
58 | implementation(projects.renderers.cupertino)
59 | implementation(projects.shared)
60 | implementation(compose.material3)
61 | implementation(compose.materialIconsExtended)
62 | implementation(compose.ui)
63 | implementation(compose.components.resources)
64 | implementation(compose.preview)
65 | implementation(libs.cupertino)
66 | implementation(libs.jetbrains.androidx.viewmodel.compose)
67 | implementation(libs.jetbrains.androidx.navigation.compose)
68 | implementation(libs.jetbrains.kotlinx.serialization.json)
69 | implementation(libs.bundles.io.ktor.client)
70 | }
71 | androidMain.dependencies {
72 | implementation(libs.androidx.activity.compose)
73 | }
74 | desktopMain.dependencies {
75 | implementation(compose.desktop.currentOs)
76 | implementation(libs.jetbrains.kotlinx.coroutines)
77 | implementation(libs.jetbrains.kotlinx.coroutines.swing)
78 | }
79 | }
80 | }
81 |
82 | tasks {
83 | withType {
84 | kotlinOptions {
85 | freeCompilerArgs = freeCompilerArgs + listOf("-opt-in=kotlin.RequiresOptIn")
86 | jvmTarget = JavaVersion.toVersion(JavaVersion.VERSION_21).toString()
87 | }
88 | }
89 |
90 | withType {
91 | val javaToolchains = project.extensions.getByType()
92 | javaCompiler.set(
93 | javaToolchains.compilerFor {
94 | languageVersion.set(JavaLanguageVersion.of(JavaVersion.VERSION_21.toString()))
95 | },
96 | )
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/ui/src/desktopTest/kotlin/com/paligot/jsonforms/ui/RendererLayoutScopeTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.ui
2 |
3 | import com.paligot.jsonforms.kotlin.models.uischema.GroupLayout
4 | import com.paligot.jsonforms.kotlin.models.uischema.HorizontalLayout
5 | import com.paligot.jsonforms.kotlin.models.uischema.LayoutOptions
6 | import com.paligot.jsonforms.kotlin.models.uischema.VerticalLayout
7 | import kotlinx.collections.immutable.persistentListOf
8 | import kotlin.test.Test
9 | import kotlin.test.assertEquals
10 | import kotlin.test.assertFalse
11 | import kotlin.test.assertTrue
12 |
13 | class RendererLayoutScopeTest {
14 | @Test
15 | fun `isVerticalLayout() returns true for VerticalLayout`() {
16 | val uiSchema = VerticalLayout()
17 | val scope: RendererLayoutScope = RendererLayoutScopeInstance(uiSchema)
18 |
19 | assertTrue(scope.isVerticalLayout())
20 | assertFalse(scope.isHorizontalLayout())
21 | assertFalse(scope.isGroupLayout())
22 | }
23 |
24 | @Test
25 | fun `isHorizontalLayout() returns true for HorizontalLayout`() {
26 | val uiSchema = HorizontalLayout()
27 | val scope: RendererLayoutScope = RendererLayoutScopeInstance(uiSchema)
28 |
29 | assertTrue(scope.isHorizontalLayout())
30 | assertFalse(scope.isVerticalLayout())
31 | assertFalse(scope.isGroupLayout())
32 | }
33 |
34 | @Test
35 | fun `isGroupLayout() returns true for GroupLayout`() {
36 | val uiSchema = GroupLayout(label = "Group Label")
37 | val scope: RendererLayoutScope = RendererLayoutScopeInstance(uiSchema)
38 |
39 | assertTrue(scope.isGroupLayout())
40 | assertFalse(scope.isVerticalLayout())
41 | assertFalse(scope.isHorizontalLayout())
42 | }
43 |
44 | @Test
45 | fun `title() returns label for GroupLayout`() {
46 | val uiSchema = GroupLayout(label = "Group Label")
47 | val scope: RendererLayoutScope = RendererLayoutScopeInstance(uiSchema)
48 |
49 | assertEquals("Group Label", scope.title())
50 | }
51 |
52 | @Test
53 | fun `description() returns description for GroupLayout`() {
54 | val uiSchema = GroupLayout(label = "Group Label", description = "Group Description")
55 | val scope: RendererLayoutScope = RendererLayoutScopeInstance(uiSchema)
56 |
57 | assertEquals("Group Description", scope.description())
58 | }
59 |
60 | @Test
61 | fun `elements() returns elements of UiSchema`() {
62 | val childElement = VerticalLayout()
63 | val uiSchema = GroupLayout(label = "Group Label", elements = persistentListOf(childElement))
64 | val scope: RendererLayoutScope = RendererLayoutScopeInstance(uiSchema)
65 |
66 | assertEquals(listOf(childElement), scope.elements())
67 | }
68 |
69 | @Test
70 | fun `verticalSpacing() returns correct spacing`() {
71 | val options = LayoutOptions(verticalSpacing = "10dp")
72 | val uiSchema = VerticalLayout(options = options)
73 | val scope: RendererLayoutScope = RendererLayoutScopeInstance(uiSchema)
74 |
75 | assertEquals("10dp", scope.verticalSpacing())
76 | }
77 |
78 | @Test
79 | fun `horizontalSpacing() returns correct spacing`() {
80 | val options = LayoutOptions(horizontalSpacing = "15dp")
81 | val uiSchema = HorizontalLayout(options = options)
82 | val scope: RendererLayoutScope = RendererLayoutScopeInstance(uiSchema)
83 |
84 | assertEquals("15dp", scope.horizontalSpacing())
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/ui/src/commonMain/kotlin/com/paligot/jsonforms/ui/Property.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.ui
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.remember
5 | import com.paligot.jsonforms.kotlin.SchemaProvider
6 | import com.paligot.jsonforms.kotlin.internal.ext.propertyKey
7 | import com.paligot.jsonforms.kotlin.models.schema.ArrayProperty
8 | import com.paligot.jsonforms.kotlin.models.schema.BooleanProperty
9 | import com.paligot.jsonforms.kotlin.models.schema.NumberProperty
10 | import com.paligot.jsonforms.kotlin.models.schema.ObjectProperty
11 | import com.paligot.jsonforms.kotlin.models.schema.Property
12 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
13 | import com.paligot.jsonforms.kotlin.models.uischema.Control
14 |
15 | @Composable
16 | internal fun Property(
17 | control: Control,
18 | schemaProvider: SchemaProvider,
19 | jsonFormState: JsonFormState,
20 | stringContent:
21 | @Composable()
22 | (RendererStringScope.(id: String) -> Unit),
23 | numberContent:
24 | @Composable()
25 | (RendererNumberScope.(id: String) -> Unit),
26 | booleanContent:
27 | @Composable()
28 | (RendererBooleanScope.(id: String) -> Unit),
29 | ) {
30 | when (schemaProvider.getPropertyByControl(control)) {
31 | is StringProperty ->
32 | StringProperty(
33 | control = control,
34 | schemaProvider = schemaProvider,
35 | jsonFormState = jsonFormState,
36 | content = stringContent,
37 | )
38 | is BooleanProperty ->
39 | BooleanProperty(
40 | control = control,
41 | schemaProvider = schemaProvider,
42 | jsonFormState = jsonFormState,
43 | content = booleanContent,
44 | )
45 | is NumberProperty ->
46 | NumberProperty(
47 | control = control,
48 | schemaProvider = schemaProvider,
49 | jsonFormState = jsonFormState,
50 | content = numberContent,
51 | )
52 | is ObjectProperty -> error("Object property can't be specified in the layout")
53 | is ArrayProperty -> TODO()
54 | }
55 | }
56 |
57 | @Composable
58 | internal fun StringProperty(
59 | control: Control,
60 | schemaProvider: SchemaProvider,
61 | jsonFormState: JsonFormState,
62 | content: @Composable RendererStringScope.(id: String) -> Unit,
63 | ) {
64 | val scope =
65 | remember(control) {
66 | RendererStringScopeInstance(control, schemaProvider, jsonFormState)
67 | }
68 | scope.content(control.propertyKey())
69 | }
70 |
71 | @Composable
72 | internal fun BooleanProperty(
73 | control: Control,
74 | schemaProvider: SchemaProvider,
75 | jsonFormState: JsonFormState,
76 | content: @Composable RendererBooleanScope.(id: String) -> Unit,
77 | ) {
78 | val scope =
79 | remember(control) {
80 | RendererBooleanScopeInstance(control, schemaProvider, jsonFormState)
81 | }
82 | scope.content(control.propertyKey())
83 | }
84 |
85 | @Composable
86 | internal fun NumberProperty(
87 | control: Control,
88 | schemaProvider: SchemaProvider,
89 | jsonFormState: JsonFormState,
90 | content: @Composable RendererNumberScope.(id: String) -> Unit,
91 | ) {
92 | val scope =
93 | remember(control) {
94 | RendererNumberScopeInstance(control, schemaProvider, jsonFormState)
95 | }
96 | scope.content(control.propertyKey())
97 | }
98 |
--------------------------------------------------------------------------------
/ui/src/desktopTest/kotlin/com/paligot/jsonforms/ui/RendererBooleanScopeTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.ui
2 |
3 | import com.paligot.jsonforms.kotlin.SchemaProvider
4 | import com.paligot.jsonforms.kotlin.models.schema.BooleanProperty
5 | import com.paligot.jsonforms.kotlin.models.uischema.Control
6 | import com.paligot.jsonforms.kotlin.models.uischema.ControlOptions
7 | import com.paligot.jsonforms.kotlin.models.uischema.Format
8 | import io.mockk.every
9 | import io.mockk.mockk
10 | import kotlin.test.Test
11 | import kotlin.test.assertEquals
12 | import kotlin.test.assertTrue
13 |
14 | class RendererBooleanScopeTest {
15 | @Test
16 | fun `test isToggle returns true when control is toggle`() {
17 | val schemaProvider = mockk()
18 | val jsonFormState = mockk()
19 | val control =
20 | Control(
21 | scope = "#/properties/check",
22 | options = ControlOptions(format = Format.Toggle),
23 | )
24 | val booleanProperty = BooleanProperty()
25 |
26 | every { schemaProvider.getPropertyByControl(control) } returns booleanProperty
27 |
28 | val scope =
29 | RendererBooleanScopeInstance(control, schemaProvider, jsonFormState, booleanProperty)
30 |
31 | assertTrue(scope.isToggle())
32 | }
33 |
34 | @Test
35 | fun `test label returns correct label`() {
36 | val schemaProvider = mockk()
37 | val jsonFormState = mockk()
38 | val control =
39 | Control(
40 | scope = "#/properties/check",
41 | label = "Test label",
42 | options = ControlOptions(format = Format.Toggle),
43 | )
44 | val booleanProperty = BooleanProperty()
45 |
46 | every { schemaProvider.getPropertyByControl(control) } returns booleanProperty
47 | every { schemaProvider.propertyIsRequired(control, any()) } returns true
48 | every { jsonFormState.getData() } returns mapOf()
49 |
50 | val scope =
51 | RendererBooleanScopeInstance(control, schemaProvider, jsonFormState, booleanProperty)
52 |
53 | assertEquals("Test label*", scope.label())
54 | }
55 |
56 | @Test
57 | fun `test description returns correct description`() {
58 | val schemaProvider = mockk()
59 | val jsonFormState = mockk()
60 | val control = mockk()
61 | val booleanProperty = BooleanProperty(description = "Test Description")
62 |
63 | every { schemaProvider.getPropertyByControl(control) } returns booleanProperty
64 |
65 | val scope =
66 | RendererBooleanScopeInstance(control, schemaProvider, jsonFormState, booleanProperty)
67 |
68 | assertEquals("Test Description", scope.description())
69 | }
70 |
71 | @Test
72 | fun `test enabled returns true when property is enabled`() {
73 | val schemaProvider = mockk()
74 | val jsonFormState = mockk()
75 | val control = Control(scope = "#/properties/check")
76 | val booleanProperty = BooleanProperty()
77 |
78 | every { schemaProvider.getPropertyByControl(control) } returns booleanProperty
79 | every { jsonFormState.getData() } returns mapOf()
80 |
81 | val scope =
82 | RendererBooleanScopeInstance(control, schemaProvider, jsonFormState, booleanProperty)
83 |
84 | assertTrue(scope.enabled())
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/paligot/jsonforms/kotlin/internal/ext/Rule.ext.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.internal.checks.validate
4 | import com.paligot.jsonforms.kotlin.models.schema.ArrayProperty
5 | import com.paligot.jsonforms.kotlin.models.schema.BooleanProperty
6 | import com.paligot.jsonforms.kotlin.models.schema.NumberProperty
7 | import com.paligot.jsonforms.kotlin.models.schema.ObjectProperty
8 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
9 | import com.paligot.jsonforms.kotlin.models.uischema.Effect
10 | import com.paligot.jsonforms.kotlin.models.uischema.Rule
11 |
12 | /**
13 | * Evaluate the rule to check if we should show the field.
14 | *
15 | * ```kotlin
16 | * val rule = Rule(
17 | * effect = Effect.Show,
18 | * condition = Condition(
19 | * scope = "#/properties/key",
20 | * schema = ConditionSchema(const = JsonPrimitive(""))
21 | * )
22 | * )
23 | * val hidden = rule.evaluateShow(mapOf("key" to ""))
24 | * ```
25 | *
26 | * @param data field values of the form.
27 | * @return true if the condition of the rule is evaluated to true.
28 | */
29 | fun Rule.evaluateShow(data: Map): Boolean {
30 | val key = condition.propertyKey()
31 | val value = data[key]
32 | val errors =
33 | when (condition.schema) {
34 | is StringProperty -> condition.schema.validate(key, value as? String ?: "")
35 | is BooleanProperty -> condition.schema.validate(key, value as? Boolean ?: false)
36 | is NumberProperty -> condition.schema.validate(key, value as? String ?: "")
37 | is ObjectProperty -> condition.schema.validate(data)
38 | is ArrayProperty -> TODO()
39 | }
40 | return if (effect == Effect.Show && errors.isEmpty()) {
41 | true
42 | } else if (effect == Effect.Show && errors.isNotEmpty()) {
43 | false
44 | } else if (effect == Effect.Hide && errors.isEmpty()) {
45 | false
46 | } else if (effect == Effect.Hide && errors.isNotEmpty()) {
47 | true
48 | } else {
49 | false
50 | }
51 | }
52 |
53 | /**
54 | * Evaluate the rule to check if we should disable the field.
55 | *
56 | * ```kotlin
57 | * val rule = Rule(
58 | * effect = Effect.Enable,
59 | * condition = Condition(
60 | * scope = "#/properties/key",
61 | * schema = ConditionSchema(const = JsonPrimitive(""))
62 | * )
63 | * )
64 | * val hidden = rule.evaluateEnabled(mapOf("key" to ""))
65 | * ```
66 | *
67 | * @param data field values of the form.
68 | * @return true if the condition of the rule is evaluated to true.
69 | */
70 | fun Rule.evaluateEnabled(data: Map): Boolean {
71 | val key = condition.propertyKey()
72 | val value = data[key]
73 | val resolve =
74 | when (condition.schema) {
75 | is StringProperty -> condition.schema.validate(key, value as? String ?: "")
76 | is BooleanProperty -> condition.schema.validate(key, value as? Boolean ?: false)
77 | is NumberProperty -> condition.schema.validate(key, value as? String ?: "")
78 | is ObjectProperty -> condition.schema.validate(data)
79 | is ArrayProperty -> TODO()
80 | }
81 | if (effect == Effect.Enable && resolve.isEmpty()) return true
82 | if (effect == Effect.Enable && resolve.isNotEmpty()) return false
83 | if (effect == Effect.Disable && resolve.isEmpty()) return false
84 | if (effect == Effect.Disable && resolve.isNotEmpty()) return true
85 | return true
86 | }
87 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/PropertyIsEnabledTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Condition
5 | import com.paligot.jsonforms.kotlin.models.uischema.Control
6 | import com.paligot.jsonforms.kotlin.models.uischema.ControlOptions
7 | import com.paligot.jsonforms.kotlin.models.uischema.Effect
8 | import com.paligot.jsonforms.kotlin.models.uischema.Rule
9 | import kotlinx.serialization.json.JsonPrimitive
10 | import kotlin.test.Test
11 | import kotlin.test.assertFalse
12 | import kotlin.test.assertTrue
13 |
14 | class PropertyIsEnabledTest {
15 | @Test
16 | fun `isEnabled should return true when control has no rule and property is not read-only`() {
17 | val property = StringProperty(readOnly = false)
18 | val control = Control(scope = "#/properties/string")
19 | val data = emptyMap()
20 |
21 | val result = property.isEnabled(control, data)
22 |
23 | assertTrue(result)
24 | }
25 |
26 | @Test
27 | fun `isEnabled should return false when property is read-only`() {
28 | val property = StringProperty(readOnly = true)
29 | val control = Control(scope = "#/properties/string")
30 | val data = emptyMap()
31 |
32 | val result = property.isEnabled(control, data)
33 |
34 | assertFalse(result)
35 | }
36 |
37 | @Test
38 | fun `isEnabled should return true when control options are not read-only`() {
39 | val property = StringProperty()
40 | val control = Control(scope = "#/properties/string", options = ControlOptions(readOnly = false))
41 | val data = emptyMap()
42 |
43 | val result = property.isEnabled(control, data)
44 |
45 | assertTrue(result)
46 | }
47 |
48 | @Test
49 | fun `isEnabled should return false when control rule with Disable effect evaluates to true`() {
50 | val property = StringProperty()
51 | val control =
52 | Control(
53 | scope = "#/properties/string",
54 | rule =
55 | Rule(
56 | effect = Effect.Disable,
57 | condition =
58 | Condition(
59 | scope = "#/properties/otherField",
60 | schema = StringProperty(const = JsonPrimitive("value")),
61 | ),
62 | ),
63 | )
64 | val data = mapOf("otherField" to "value")
65 |
66 | val result = property.isEnabled(control, data)
67 |
68 | assertFalse(result)
69 | }
70 |
71 | @Test
72 | fun `isEnabled should return true when control rule with Enable effect evaluates to true`() {
73 | val property = StringProperty()
74 | val control =
75 | Control(
76 | scope = "#/properties/string",
77 | rule =
78 | Rule(
79 | effect = Effect.Enable,
80 | condition =
81 | Condition(
82 | scope = "#/properties/otherField",
83 | schema = StringProperty(const = JsonPrimitive("value")),
84 | ),
85 | ),
86 | )
87 | val data = mapOf("otherField" to "value")
88 |
89 | val result = property.isEnabled(control, data)
90 |
91 | assertTrue(result)
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/paligot/jsonforms/kotlin/internal/ext/RuleEvaluateEnabledTest.kt:
--------------------------------------------------------------------------------
1 | package com.paligot.jsonforms.kotlin.internal.ext
2 |
3 | import com.paligot.jsonforms.kotlin.models.schema.StringProperty
4 | import com.paligot.jsonforms.kotlin.models.uischema.Condition
5 | import com.paligot.jsonforms.kotlin.models.uischema.Effect
6 | import com.paligot.jsonforms.kotlin.models.uischema.Rule
7 | import kotlinx.serialization.json.JsonPrimitive
8 | import kotlin.test.Test
9 | import kotlin.test.assertFalse
10 | import kotlin.test.assertTrue
11 |
12 | class RuleEvaluateEnabledTest {
13 | @Test
14 | fun `evaluateEnabled should return true when effect is Enable and condition resolves to true`() {
15 | val rule =
16 | Rule(
17 | effect = Effect.Enable,
18 | condition =
19 | Condition(
20 | scope = "#/properties/key",
21 | schema = StringProperty(const = JsonPrimitive("value")),
22 | ),
23 | )
24 | val data = mapOf("key" to "value")
25 |
26 | val result = rule.evaluateEnabled(data)
27 |
28 | assertTrue(result)
29 | }
30 |
31 | @Test
32 | fun `evaluateEnabled should return false when effect is Enable and condition resolves to false`() {
33 | val rule =
34 | Rule(
35 | effect = Effect.Enable,
36 | condition =
37 | Condition(
38 | scope = "#/properties/key",
39 | schema = StringProperty(const = JsonPrimitive("value")),
40 | ),
41 | )
42 | val data = mapOf("key" to "otherValue")
43 |
44 | val result = rule.evaluateEnabled(data)
45 |
46 | assertFalse(result)
47 | }
48 |
49 | @Test
50 | fun `evaluateEnabled should return false when effect is Disable and condition resolves to true`() {
51 | val rule =
52 | Rule(
53 | effect = Effect.Disable,
54 | condition =
55 | Condition(
56 | scope = "#/properties/key",
57 | schema = StringProperty(const = JsonPrimitive("value")),
58 | ),
59 | )
60 | val data = mapOf("key" to "value")
61 |
62 | val result = rule.evaluateEnabled(data)
63 |
64 | assertFalse(result)
65 | }
66 |
67 | @Test
68 | fun `evaluateEnabled should return true when effect is Disable and condition resolves to false`() {
69 | val rule =
70 | Rule(
71 | effect = Effect.Disable,
72 | condition =
73 | Condition(
74 | scope = "#/properties/key",
75 | schema = StringProperty(const = JsonPrimitive("value")),
76 | ),
77 | )
78 | val data = mapOf("key" to "otherValue")
79 |
80 | val result = rule.evaluateEnabled(data)
81 |
82 | assertTrue(result)
83 | }
84 |
85 | @Test
86 | fun `evaluateEnabled should return true when condition schema is empty`() {
87 | val rule =
88 | Rule(
89 | effect = Effect.Enable,
90 | condition =
91 | Condition(
92 | scope = "#/properties/key",
93 | schema = StringProperty(),
94 | ),
95 | )
96 | val data = mapOf("key" to "value")
97 |
98 | val result = rule.evaluateEnabled(data)
99 |
100 | assertTrue(result)
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/docs/custom-rendering.md:
--------------------------------------------------------------------------------
1 | This page explains how to customize the default rendering of a `JsonForm` component in
2 | `jsonforms-kotlin`. You can provide your own UI for specific fields or layouts by supplying custom
3 | composable functions to the `JsonForm` component. This allows you to tailor the form's appearance
4 | and behavior to your application's needs.
5 |
6 | ## Why Customize Rendering?
7 |
8 | While the default renderers (such as Material3 or Cupertino) provide a consistent look and feel,
9 | you may want to:
10 |
11 | * Integrate custom UI components (e.g., dropdowns, country pickers)
12 | * Change the layout or style of certain fields
13 | * Add special logic for user interaction
14 |
15 | ## How to Customize Rendering
16 |
17 | You can customize rendering by providing your own implementations for the `layoutContent`,
18 | `stringContent`, `numberContent`, and `booleanContent` slots in the `JsonForm` composable. Each
19 | slot receives the necessary context (such as the field id and current value) and should emit your
20 | custom ui component.
21 |
22 | **Example: Custom dropdown for country selection**
23 |
24 | Here, the `flags` field uses a custom dropdown, while other fields use the default Material3
25 | renderer:
26 |
27 | ```kotlin
28 | JsonForm(
29 | schema = schema,
30 | uiSchema = uiSchema,
31 | state = state,
32 | layoutContent = { Material3Layout(content = it) },
33 | stringContent = { id ->
34 | val value = state[id].value as String?
35 | val error = state.error(id = id).value
36 | if (id == "flags") {
37 | FlagDropdownField(
38 | value = value,
39 | values = values(), // your list of country codes described in the schema
40 | expanded = expanded,
41 | onFlagClick = { expanded = !expanded },
42 | onItemClick = { state[id] = it },
43 | onDismissRequest = { expanded = false },
44 | )
45 | } else {
46 | Material3StringProperty(
47 | value = value,
48 | error = error?.message,
49 | onValueChange = { state[id] = it },
50 | )
51 | }
52 | },
53 | numberContent = {},
54 | booleanContent = {},
55 | )
56 | ```
57 |
58 | * The `stringContent` lambda checks the field id. If it's `flags`, it renders a custom dropdown. Otherwise, it falls back to the default Material3 field.
59 | * You can use this pattern to override rendering for any field or layout.
60 |
61 | ## Accessing data from schema and UI schema
62 |
63 | When customizing rendering, the `stringContent`, `numberContent`, and `booleanContent` slots each
64 | receive a specialized scope object: `RendererStringScope`, `RendererNumberScope`, or
65 | `RendererBooleanScope`.
66 |
67 | These scope interfaces provide a rich API to access metadata and configuration for the field being
68 | rendered, by connecting the UI schema (which describes the form layout and controls) with the JSON
69 | schema (which describes the data model and validation rules). The internal implementation of each
70 | scope uses both schemas and the current form state to provide the following:
71 |
72 | ```kotlin
73 | stringContent = { id ->
74 | val value = state[id].value as String?
75 | val error = state.error(id).value
76 | val label = this.label()
77 | val enabled = this.enabled()
78 | // ...render your custom field using this metadata...
79 | }
80 | ```
81 |
82 | ## Tips for Customization
83 |
84 | * You can mix and match custom and default renderers as needed.
85 | * Use the `state` object to read and update field values and errors.
86 | * You can provide custom logic for any field type (string, number, boolean, etc.) by implementing the corresponding content slot.
87 |
88 | For more details, see the [usage guide](usage.md) or the [API reference](api/index.html).
89 |
--------------------------------------------------------------------------------
/docs/create-renderer.md:
--------------------------------------------------------------------------------
1 | This page explains how to create your own renderer for `jsonforms-kotlin`, allowing you to fully
2 | customize the look and feel of forms to match your internal design system or branding. This is
3 | useful if the provided renderers do not meet your requirements.
4 |
5 | ## What is a renderer?
6 |
7 | A renderer is a set of composable functions that define how each form field and layout should be
8 | displayed. You implement these by providing composable extensions for the various scope interfaces:
9 | `RendererStringScope`, `RendererNumberScope`, `RendererBooleanScope`, and `RendererLayoutScope`.
10 |
11 | ## Steps to create a custom renderer
12 |
13 | **Implement field renderers**: Create composable extension functions for each field type and use the scope API to access field metadata, options, and state.
14 |
15 | * `RendererStringScope.YourStringProperty(...)`
16 | * `RendererNumberScope.YourNumberProperty(...)`
17 | * `RendererBooleanScope.YourBooleanProperty(...)`
18 |
19 | **Implement layout renderer**: Create a composable extension for `RendererLayoutScope`
20 | (e.g., `YourLayout(...)`) to control how groups of fields are arranged (vertical, horizontal, etc).
21 |
22 | **Use your Renderer in JsonForm**: Pass your custom composables to the `JsonForm` component's
23 | `layoutContent`, `stringContent`, `numberContent`, and `booleanContent` slots.
24 |
25 | **Example: Minimal custom renderer**
26 |
27 | ```kotlin
28 | @Composable
29 | fun RendererStringScope.MyStringProperty(
30 | value: String?,
31 | error: String? = null,
32 | onValueChange: (String) -> Unit,
33 | ) {
34 | // Use scope methods for label, enabled, etc.
35 | MyTextField(
36 | value = value ?: "",
37 | label = label(),
38 | enabled = enabled(),
39 | error = error,
40 | onValueChange = onValueChange
41 | )
42 | }
43 |
44 | @Composable
45 | fun RendererNumberScope.MyNumberProperty(
46 | value: String?,
47 | error: String? = null,
48 | onValueChange: (String) -> Unit,
49 | ) {
50 | MyNumberField(
51 | value = value ?: "",
52 | label = label(),
53 | enabled = enabled(),
54 | error = error,
55 | onValueChange = onValueChange
56 | )
57 | }
58 |
59 | @Composable
60 | fun RendererBooleanScope.MyBooleanProperty(
61 | value: Boolean,
62 | onValueChange: (Boolean) -> Unit,
63 | ) {
64 | MySwitch(
65 | checked = value,
66 | label = label(),
67 | enabled = enabled(),
68 | onCheckedChange = onValueChange
69 | )
70 | }
71 |
72 | @Composable
73 | fun RendererLayoutScope.MyLayout(
74 | content: @Composable (UiSchema) -> Unit
75 | ) {
76 | Column {
77 | elements().forEach { child ->
78 | content(child)
79 | }
80 | }
81 | }
82 | ```
83 |
84 | ## Using your renderer
85 |
86 | ```kotlin
87 | JsonForm(
88 | schema = schema,
89 | uiSchema = uiSchema,
90 | state = state,
91 | layoutContent = { MyLayout(content = it) },
92 | stringContent = { scope ->
93 | MyStringProperty(
94 | value = state[scope.id].value as String?,
95 | error = state.error(scope.id).value,
96 | onValueChange = { state[scope.id] = it }
97 | )
98 | },
99 | numberContent = { scope ->
100 | MyNumberProperty(
101 | value = state[scope.id].value as String?,
102 | error = state.error(scope.id).value,
103 | onValueChange = { state[scope.id] = it }
104 | )
105 | },
106 | booleanContent = { scope ->
107 | MyBooleanProperty(
108 | value = state[scope.id].value as Boolean? ?: false,
109 | onValueChange = { state[scope.id] = it }
110 | )
111 | }
112 | )
113 | ```
114 |
115 | For more details, see the [API reference](api/index.html) and the source code of the
116 | material3 and cupertino modules.
117 |
--------------------------------------------------------------------------------