diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..153d416 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/README.md b/README.md index 66a2ad4..0f2d3d9 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,208 @@ # Formica -A Kotlin Compose Multiplatform library for managing form states, validations, and data binding in a -declarative way. This library aims to simplify form handling in Compose by providing hooks and -utilities for managing input states, validating fields, and handling errors +**Formica** is a **Kotlin Compose Multiplatform** library for managing **form state**, +**validation**, and **data binding** in a fully declarative way. +It eliminates boilerplate by giving you **reactive fields**, **built-in validators**, and +**lens-based binding** for immutable form models. -### Installation +--- + +## Features + +- **Immutable form data model** — no reflection, no direct mutation. +- **Reactive fields** — each field tracks value, error, touched, dirty. +- **Composable API** — `FormicaField` for inline field binding, `FormicaFieldState` for external + field state. +- **Built-in validation rules** — or plug in your own. +- **Scoped provider** — share the form across nested composables without prop drilling. +- **Type-safe** — works with your data class directly. + +--- + +## Installation Add the following dependency to your project: ```gradle dependencies { - implementation("dev.voir.formica:1.0.0-alpha01") // Not available on public now, you can publish to mavenLocal. + implementation("dev.voir.formica:1.0.0-alpha02") // Not available on public now, you can publish to mavenLocal. } ``` -### How to use? +## Getting started -Create your form schema +1. Define your form data schema ```kotlin data class FormSchema( val firstName: String, val lastName: String?, - val email: String + val email: String, + val subscribe: Boolean +) +``` + +2. Create Field IDs (lenses) + +```kotlin +val FirstName = FormicaFieldId( + id = "firstName", + get = { it.firstName }, + set = { data, value -> data.copy(firstName = value) }, + clear = { data -> data.copy(firstName = "") } +) + +val LastName = FormicaFieldId( + id = "lastName", + get = { it.lastName }, + set = { data, value -> data.copy(lastName = value) }, + clear = { data -> data.copy(lastName = null) } +) + +val Email = FormicaFieldId( + id = "email", + get = { it.email }, + set = { data, value -> data.copy(email = value) } +) + +val Subscribe = FormicaFieldId( + id = "subscribe", + get = { it.subscribe }, + set = { data, value -> data.copy(subscribe = value) } ) ``` -Initialize form in your ```kotlin @Composable``` +3. Provide the form ```kotlin @Composable -fun YourFormComponent() { +fun YourFormScreen() { val form = rememberFormica( - // Pass some initial data if necessary - initialData = FormSchema( - firstName = "", - lastName = null, - email = "" - ) + initialData = FormSchema("", null, "", false), + onSubmit = { data -> + println("Form submitted: $data") + } ) - // You can access current form data as state - val formData by form.collectDataAsState() - // Or you can access current form result as state - val formResult by form.collectResultAsState() + FormicaProvider(form) { + YourFormContent() + } +} +``` + +4. Build form UI + +```kotlin +@Composable +fun YourFormContent() { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + + // Text field example + FormicaField( + id = FirstName, + validators = setOf( + ValidationRules.minLength(2, "First name must be at least 2 characters"), + ValidationRules.maxLength(64, "First name must not exceed 64 characters") + ) + ) { field -> + TextField( + value = field.value.orEmpty(), + onValueChange = field.onChange, + label = { Text("First Name") }, + isError = field.error != null + ) + field.error?.let { Text(it, color = Color.Red) } + } + + // Checkbox example + FormicaField(id = Subscribe) { field -> + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = field.value ?: false, + onCheckedChange = field.onChange + ) + Text("Subscribe to newsletter") + } + } - // Pass formica instance - Column { - Formica(form) { + // Conditional field + FormFieldPresence( + form = formicaOf(), + id = Subscribe, + present = formicaOf().data.value.subscribe + ) { FormicaField( - name = FormSchema::firstName, // Bind property from schema with FormicaField + id = Email, validators = setOf( - // Pass validation rules - MinLengthRule( - 2, - message = "First name should be at least 2 characters" - ), - MaxLengthRule( - 64, - message = "First name should not be greater than 64 characters" + ValidationRules.validateOnlyIf( + active = { formicaOf().data.value.subscribe }, + rule = ValidationRules.email() ) ) - ) { - // Access current field value with field.value - // Modify value in form using onChange callback - TextField(text = field.value, onValueChange = onChange(FormSchema::firstName, it)) + ) { field -> + TextField( + value = field.value.orEmpty(), + onValueChange = field.onChange, + label = { Text("Email Address") }, + isError = field.error != null + ) + field.error?.let { Text(it, color = Color.Red) } } - /* Other form fields... */ } + Spacer(Modifier.height(16.dp)) + Button(onClick = { - // Validate form on submit - val result = form.validate() + val result = formicaOf().submit() if (result is FormicaResult.Valid) { - // Form is valid, do some action... - } else if (result is FormicaResult.Error) { - // Form is not valid, check validation errors + println("Form valid, submitting...") } }) { - Text("Submit form") + Text("Submit") } } } ``` + +## How to? + +#### 1. Reading Field State Outside the Field + +Sometimes you want to react to a field’s state elsewhere in the UI: + +```kotlin +val subscribeState = rememberFormicaFieldState(Subscribe) + +if (subscribeState?.value == true) { + Text("Thanks for subscribing!") +} +``` + +#### 2. Use validation Rules + +Formica ships with a set of ready-to-use rules: + +```kotlin +ValidationRules.required() +ValidationRules.email() +ValidationRules.minLength(3) +ValidationRules.maxLength(50) +ValidationRules.range(0, 100) +ValidationRules.checked() +ValidationRules.validateOnlyIf({ condition }, ValidationRules.required()) +``` + +Or create your own: + +```kotlin +val mustBeFoo = ValidationRule { v -> + if (v == "foo") FormicaFieldResult.Success + else FormicaFieldResult.Error("Must be 'foo'") +} +``` + +## License + +This project is licensed under the GNU Lesser General Public License v3.0. + +See the full license at https://www.gnu.org/licenses/lgpl-3.0.txt diff --git a/formica/build.gradle.kts b/formica/build.gradle.kts index c2c7fe8..bebc9c3 100644 --- a/formica/build.gradle.kts +++ b/formica/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } group = "dev.voir" -version = "1.0.0-alpha01" +version = "1.0.0-alpha02" kotlin { androidTarget { diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt b/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt index 75c1ca4..b390c12 100644 --- a/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt +++ b/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt @@ -1,40 +1,144 @@ package dev.voir.formica -import dev.voir.formica.rules.ValidationRule import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlin.reflect.KMutableProperty1 -sealed class FormicaResult { - data object NoInput : FormicaResult() - data object Valid : FormicaResult() - data class Error(val message: String) : FormicaResult() -} +/** + * A form state container that: + * - Holds a reactive [data] snapshot (immutable [Data]) + * - Manages a registry of fields (each field keeps its own reactive state & validation) + * - Provides typed updates via [onChange], and form-level [validate]/[submit] workflows + * - Supports clearing & syncing from external data sources + * + * ### Key ideas + * - **No reflection:** fields are registered with [FormicaFieldId] (lens-like accessors). + * - **Immutable data:** every write returns a new [Data], updating [_data] so UI can react. + * - **Field state is separate:** each field maintains `value/error/result/touched/dirty`, but the + * canonical committed model is always [data]. + * - **Validation pipeline:** `FormicaField` handles required/validators/custom. Here we just call `validate()` + * across all registered fields and aggregate errors. + */ class Formica(val initialData: Data, private val onSubmit: ((Data) -> Unit)? = null) { - private val fields: MutableMap, FormicaField> = mutableMapOf() + /** + * Registry of fields by their [FormicaFieldId.id]. + * Pair = (lens, field-state). + * + * NOTE: We use `Any?` erasure behind the scenes; public APIs remain strongly typed. + */ + private val fields = + mutableMapOf, FormicaField>>() + + /** + * Reactive, immutable snapshot of the entire form data model. + * Every successful [onChange]/[clear] produces a new instance. + */ + private val _data = MutableStateFlow(initialData) + val data: StateFlow get() = _data + + /** + * Reactive result of the last form-level validation/submit attempt. + * - Starts as [FormicaResult.NoInput] + * - Changes to [FormicaResult.Valid] or [FormicaResult.Error] after [validate]/[submit] + */ + private val _result = MutableStateFlow(FormicaResult.NoInput) + val result: StateFlow get() = _result + + /** + * Register a field for this form. Must be called once per field you plan to use. + * + * @param id Lens describing how to read/write the field on [Data]. + * @param validators ORDERED set of validators for the field (first failure short-circuits). + * (Kotlin's default `setOf(...)` is insertion-ordered / LinkedHashSet.) + * @param customValidation Optional rule run *after* all validators. + * @param validateOnChange If true, field validates automatically on value changes. + * + * @return The created [FormicaField] (reactive value/error/etc.). + * + * You can conditionally *render* inputs, but you should *register* fields once to keep + * state stable across composition toggles. + */ + fun registerField( + id: FormicaFieldId, + validators: Set>, + customValidation: ((Value?) -> FormicaFieldResult)? = null, + validateOnChange: Boolean = true, + ): FormicaField { + val field = FormicaField( + initialValue = id.get(_data.value), // seed from current data snapshot + validators = validators, + customValidation = customValidation, + validateOnChange = validateOnChange + ) + // Store in registry with erased generics + fields[id.id] = (id as FormicaFieldId) to (field as FormicaField) + + return field + } - private val _data: MutableStateFlow = MutableStateFlow(initialData) - val data: StateFlow - get() = _data + /** + * Retrieve a previously registered field (for reading value/error/touched/dirty externally). + * Returns null if the field hasn't been registered in this form. + */ + fun getRegisteredField(id: FormicaFieldId): FormicaField? { + val pair = fields[id.id] ?: return null + @Suppress("UNCHECKED_CAST") + return pair.second as? FormicaField + } + + /** + * Apply a single-field update and propagate it to both: + * 1) The field's reactive state (value/touched/dirty and optional per-change validation) + * 2) The immutable [data] snapshot via lens set/clear + * + * @param id Lens for the field + * @param value New value, or `null` to indicate "clear". If `null` and [FormicaFieldId.clear] is not set, + * the data snapshot remains unchanged (field state still updates). + */ + fun onChange(id: FormicaFieldId, value: V?) { + val pair = fields[id.id] ?: return + val (lens, field) = pair - private val _result: MutableStateFlow = MutableStateFlow(FormicaResult.NoInput) - val result: StateFlow - get() = _result + // 1) Update field reactive state (value/touched/dirty + maybe validate) + @Suppress("UNCHECKED_CAST") + (field as FormicaField).onChange(value) + + // 2) Update immutable data snapshot via lens + @Suppress("UNCHECKED_CAST") + val l = lens as FormicaFieldId + _data.value = if (value == null) { + l.clear?.invoke(_data.value) ?: _data.value + } else { + l.set(_data.value, value) + } + } /** - * Validate all fields in the form - * @return Result of the form validation + * Validate all registered fields and update [result] accordingly. + * + * @return [FormicaResult.Valid] if all fields are valid, otherwise [FormicaResult.Error] + * with a map of `fieldId -> message`. + * + * Field-level validation order is handled inside each [FormicaField]. */ fun validate(): FormicaResult { - val errors = fields.map { field -> - field.value.isValid() + val errors = mutableMapOf() + + for ((key, pair) in fields) { + val (_, field) = pair + val res = field.validate() + if (res is FormicaFieldResult.Error) { + errors[key] = res.message + } } - val newState = if (errors.all { it }) { + + val newState = if (errors.isEmpty()) { FormicaResult.Valid } else { - // TODO Pass information about invalid fields - FormicaResult.Error(message = "Some fields not valid") + FormicaResult.Error( + message = "Some fields are not valid", // TODO This message can be changed + fieldErrors = errors.toMap() + ) } _result.value = newState @@ -42,48 +146,42 @@ class Formica(val initialData: Data, private val onSubmit: ((Data) -> Unit } /** - * Validate all form fields and if form is valid call onSubmit callback + * Bring all registered fields' initial/value state back in sync with the current [data] snapshot. + * + * Useful after you changed [_data] externally (e.g., loaded a draft, applied a server patch). + * Resets each field to "pristine" (NoInput, not dirty/touched). */ - fun submit() { - val result = validate() - if (result is FormicaResult.Valid) { - onSubmit?.let { it(data.value) } + fun syncFromData() { + for ((_, pair) in fields) { + val (lens, field) = pair + val v = lens.get(_data.value) + field.reset(v) } + _result.value = FormicaResult.NoInput } /** - * Change form value by property name - * @param property Field name, e.g FormScheme::firstName - * @param value New value of the field + * Validate and, if successful, invoke [onSubmit] with the latest [data] snapshot. + * + * @return the validation result so callers can branch on it. */ - fun onChange(property: KMutableProperty1, value: Value) { - val newData = data.value - property.setValue(newData, property, value) - _data.value = newData - fields[property]?.onChange(value) + fun submit(): FormicaResult { + val r = validate() + if (r is FormicaResult.Valid) { + onSubmit?.invoke(_data.value) + } + return r } /** - * Register field in the form + * Explicitly clear a field on the immutable [data] snapshot using its [FormicaFieldId.clear] + * (if provided). Does *not* touch the field's reactive value; combine with + * `getRegisteredField(...).reset(...)` or `onChange(id, null)` if you also want to + * update the field state. */ - fun registerField( - name: KMutableProperty1, - required: Boolean, - validators: Set>, - customValidation: ((Value?) -> FormicaFieldResult)? = null, - validateOnChange: Boolean = true, - requiredError: String? = null, - ): FormicaField { - val field = FormicaField( - initialValue = name.get(data.value), - required = required, - requiredError = requiredError, - validators = validators, - customValidation = customValidation, - validateOnChange = validateOnChange - ) - fields[name] = field as FormicaField - - return field + fun clear(id: FormicaFieldId) { + val pair = fields[id.id] ?: return + val lens = pair.first + (lens.clear ?: return)(_data.value).also { _data.value = it } } } diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt index 6c70436..8041320 100644 --- a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt +++ b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt @@ -1,89 +1,164 @@ package dev.voir.formica -import dev.voir.formica.rules.ValidationRule import kotlinx.coroutines.flow.MutableStateFlow -sealed class FormicaFieldResult { - data object Success : FormicaFieldResult() - - data class Error(val message: String) : FormicaFieldResult() - - data object NoInput : FormicaFieldResult() -} - -class FormicaField( +/** + * Represents a single field inside a form. + * + * This class encapsulates: + * - The field's current value + * - Validation rules and results + * - UI-friendly state flags (touched, dirty) + * - Optional enable/disable (presence) flag + * + * Generic type [Value] can be nullable or non-null, but internally we always store it + * as `Value?` in [value] to allow temporarily "empty" states. + */ +class FormicaField( initialValue: Value, - private val required: Boolean, - private val validators: Set> = emptySet(), + /** + * A set of built-in or attached validation rules to run in order. + * Each rule is a [ValidationRule] applied before [customValidation]. + * Order matters: the first failing rule will short-circuit validation. + */ + private val validators: Set> = emptySet(), + + /** + * A custom validation function run *after* all [validators]. + * Allows for cross-field or complex validation logic. + */ private val customValidation: ((Value?) -> FormicaFieldResult)? = null, + + /** + * Whether the field should validate itself automatically every time its value changes. + * If false, you must call [validate] manually (e.g., on form submit). + */ private val validateOnChange: Boolean = true, - private val requiredError: String? = null, ) { - val value: MutableStateFlow = MutableStateFlow(initialValue) - val error: MutableStateFlow = MutableStateFlow(null) - private val result: MutableStateFlow = - MutableStateFlow(FormicaFieldResult.NoInput) + /** + * Current value of the field (nullable so that "empty" can be represented). + * Observed by UI to display current input. + */ + val value = MutableStateFlow(initialValue) + + /** + * The last error message for this field, or null if valid. + * Updated during validation. Observed by UI to show error messages. + */ + val error = MutableStateFlow(null) + + /** + * The last validation result: Success, Error, or NoInput. + */ + val result = MutableStateFlow(FormicaFieldResult.NoInput) + + /** + * Whether the field has been interacted with (first change made). + * Useful for deciding when to show validation errors (e.g., on blur). + */ + val touched = MutableStateFlow(false) + + /** + * Whether the value has changed from its initial value. + * Often used to enable/disable a "Save" button. + */ + val dirty = MutableStateFlow(false) + + /** + * Snapshot of the initial value for dirty checking and reset logic. + */ + private var initial = initialValue /** - * Update field value with new one + * Whether the field is "enabled" (present in form) for validation purposes. + * If disabled, validation will always return Success and skip validators. + */ + private val enabled = MutableStateFlow(true) + + /** + * Enable or disable the field for validation. + * When disabled, validators and customValidation will be skipped. + */ + fun setEnabled(v: Boolean) { + enabled.value = v + } + + + /** + * Called when the user changes the value. + * + * Updates: + * - [touched]: true after the first change + * - [dirty]: true if value != initial + * - [value]: set to new input * - * @param input New field value + * Optionally triggers validation immediately if [validateOnChange] is true. */ fun onChange(input: Value?) { + if (!touched.value) touched.value = true + dirty.value = (input != initial) value.value = input + if (validateOnChange) validate() + } - if (validateOnChange) { - validate(input) - } + + /** + * Reset the field to a new initial value. + * Clears errors and flags, resets result to NoInput. + */ + fun reset(newInitial: Value) { + initial = newInitial + value.value = newInitial + error.value = null + result.value = FormicaFieldResult.NoInput + touched.value = false + dirty.value = false } /** - * Validate field - * - * @return true if field is valid, otherwise false - */ - fun isValid(): Boolean = validate(value.value) is FormicaFieldResult.Success - - private fun validate(input: Value?): FormicaFieldResult { - if (customValidation != null) { - val result = customValidation.invoke(input) - this.result.value = result - if (result is FormicaFieldResult.Error) { - this.error.value = result.message - } else { - this.error.value = null - } - return result - } + * Convenience: return true if [validate] passes. + */ + fun isValid(): Boolean = validate() is FormicaFieldResult.Success - // If field is optional and value is null, than everything fine - // If field is required and value is null, than show required error - if (!required && input == null) { - this.result.value = FormicaFieldResult.Success - this.error.value = null + + /** + * Run validation on the current value: + * + * 1. If disabled ([enabled] == false), skip and mark Success. + * 2. Run [validators] in order, short-circuit on first Error. + * 3. Run [customValidation] last if present. + * 4. Store final result in [result] and update [error] if applicable. + */ + fun validate(): FormicaFieldResult { + // Disabled? skip everything and mark as valid + if (!enabled.value) { + error.value = null + result.value = FormicaFieldResult.Success return FormicaFieldResult.Success - } else if (required && input == null) { - val message = requiredError ?: "Field is required" - val result = FormicaFieldResult.Error(message = message) + } - this.result.value = result - this.error.value = result.message + val v = value.value - return result + // Run built-in/attached validators first (ordered) + for (rule in validators) { + when (val r = rule.validate(v)) { + is FormicaFieldResult.Error -> return setError(r) + else -> {} + } } - // Run Validators - val validationResult = validators.map { it.validate(input!!) } - val result = - validationResult.find { it is FormicaFieldResult.Error } ?: FormicaFieldResult.Success + // Custom validation last + val r = customValidation?.invoke(v) ?: FormicaFieldResult.Success + return setError(r) + } - this.result.value = result - if (result is FormicaFieldResult.Error) { - this.error.value = result.message - } else { - this.error.value = null - } - return result + /** + * Internal helper to update [result] and [error] flows together. + */ + private fun setError(r: FormicaFieldResult): FormicaFieldResult { + result.value = r + error.value = (r as? FormicaFieldResult.Error)?.message + return r } } diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaFieldId.kt b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaFieldId.kt new file mode 100644 index 0000000..decd208 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaFieldId.kt @@ -0,0 +1,36 @@ +package dev.voir.formica + +/** + * A lightweight, reflection-free *lens* describing how to read/write a single field [V] on a model [Data]. + * + * - [id] must be a stable, unique key (e.g., "firstName"). It's used to store/lookup the field in the form. + * - [get] reads the current value of the field from [Data]. + * - [set] returns a **new** [Data] instance with the field updated (immutability by design). + * - [clear] optionally returns a **new** [Data] with the field cleared (e.g., to `null` or default), + * used when `onChange(..., value = null)` is invoked. If absent, `null` updates are ignored. + * + * This avoids kotlin-reflect and works great for KMP. Define them next to your data model: + * + * ```kotlin + * data class Profile(val firstName: String, val note: String?) + * + * val FirstName = FormicaFieldId( + * id = "firstName", + * get = { it.firstName }, + * set = { d, v -> d.copy(firstName = v) } + * ) + * + * val Note = FormicaFieldId( + * id = "note", + * get = { it.note }, + * set = { d, v -> d.copy(note = v) }, + * clear = { d -> d.copy(note = null) } + * ) + * ``` + */ +class FormicaFieldId( + val id: String, // Stable key (e.g., "firstName") + val get: (Data) -> V, // Read from Data + val set: (Data, V) -> Data, // Return a *new* Data with V set (immutable update) + val clear: ((Data) -> Data)? = null // Optional immutable "clear" operation +) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaResults.kt b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaResults.kt new file mode 100644 index 0000000..c31d6a2 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaResults.kt @@ -0,0 +1,18 @@ +package dev.voir.formica + +sealed class FormicaResult { + data object NoInput : FormicaResult() + data object Valid : FormicaResult() + data class Error( + val message: String, + val fieldErrors: Map = emptyMap() + ) : FormicaResult() +} + +sealed class FormicaFieldResult { + data object Success : FormicaFieldResult() + + data class Error(val message: String) : FormicaFieldResult() + + data object NoInput : FormicaFieldResult() +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ValidationRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ValidationRule.kt new file mode 100644 index 0000000..3353a78 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/ValidationRule.kt @@ -0,0 +1,206 @@ +package dev.voir.formica + +fun interface ValidationRule { + fun validate(value: V): FormicaFieldResult +} + +object ValidationRules { + fun validateOnlyIf(active: () -> Boolean, rule: ValidationRule) = + ValidationRule { v -> if (active()) rule.validate(v) else FormicaFieldResult.Success } + + fun required( + message: String = "Field is required", + isEmpty: (V?) -> Boolean = { v -> + when (v) { + null -> true + is String -> v.isBlank() + is Collection<*> -> v.isEmpty() + else -> false + } + } + ): ValidationRule = ValidationRule { v -> + if (isEmpty(v)) { + FormicaFieldResult.Error(message) + } else { + FormicaFieldResult.Success + } + } + + /* TODO Maybe useful + fun requiredIf( + form: Formica, + predicate: (D) -> Boolean, + message: String + ): ValidationRule = ValidationRule { v -> + val active = predicate(form.data.value) // read live snapshot + if (!active) return@ValidationRule FormicaFieldResult.Success + + val empty = when (v) { + null -> true + is String -> v.isBlank() + is Collection<*> -> v.isEmpty() + else -> false + } + if (empty) FormicaFieldResult.Error(message) else FormicaFieldResult.Success + }*/ + + + fun notEmpty( + message: String = "This field cannot be empty." + ): ValidationRule = + ValidationRule { v -> + when { + v == null -> FormicaFieldResult.NoInput + v.isNotEmpty() -> FormicaFieldResult.Success + else -> FormicaFieldResult.Error(message) + } + } + + fun notBlank( + message: String = "This field cannot be blank." + ): ValidationRule = + ValidationRule { v -> + when { + v == null -> FormicaFieldResult.NoInput + v.isNotBlank() -> FormicaFieldResult.Success + else -> FormicaFieldResult.Error(message) + } + } + + fun email( + message: String = "Must be a valid email address.", + pattern: Regex = EMAIL_PATTERN.toRegex() + ): ValidationRule = + ValidationRule { v -> + when { + v == null -> FormicaFieldResult.NoInput + v.matches(pattern) -> FormicaFieldResult.Success + else -> FormicaFieldResult.Error(message) + } + } + + fun strongPassword( + minLength: Int = 8, + lengthMessage: String = "Password must be at least $minLength characters long.", + uppercaseMessage: String = "Password must contain at least one uppercase letter.", + lowercaseMessage: String = "Password must contain at least one lowercase letter.", + digitMessage: String = "Password must contain at least one digit.", + specialCharacterMessage: String = "Password must contain at least one special character.", + ): ValidationRule = + ValidationRule { v -> + when { + v == null -> FormicaFieldResult.NoInput + v.length < minLength -> FormicaFieldResult.Error(lengthMessage) + !v.any { it.isUpperCase() } -> FormicaFieldResult.Error(uppercaseMessage) + !v.any { it.isLowerCase() } -> FormicaFieldResult.Error(lowercaseMessage) + !v.any { it.isDigit() } -> FormicaFieldResult.Error(digitMessage) + !v.any { it in "!@#$%^&*()-_=+[]{};:'\",.<>?/|\\`~" } -> FormicaFieldResult.Error( + specialCharacterMessage + ) + + else -> FormicaFieldResult.Success + } + } + + fun url( + protocolRequired: Boolean = false, + message: String = "Must be a valid URL." + ): ValidationRule = ValidationRule { v -> + if (v == null) { + return@ValidationRule FormicaFieldResult.NoInput + } + + val result = if (protocolRequired) { + v.matches(HTTP_URL_PATTERN.toRegex()) + } else { + v.matches(HTTP_URL_PATTERN.toRegex()) || v.matches(DOMAIN_URL_PATTERN.toRegex()) + } + + if (result) { + FormicaFieldResult.Success + } else { + FormicaFieldResult.Error(message) + } + } + + fun checked(message: String = "Must be checked"): ValidationRule = + ValidationRule { v -> + if (v == null) { + return@ValidationRule FormicaFieldResult.NoInput + } + + if (v) { + FormicaFieldResult.Success + } else { + FormicaFieldResult.Error(message) + } + } + + fun minLength( + option: Int, + message: String = "Must be at least $option characters long.", + ): ValidationRule = ValidationRule { v -> + if (v == null) { + return@ValidationRule FormicaFieldResult.NoInput + } + if (v.count() >= option) { + FormicaFieldResult.Success + } else { + FormicaFieldResult.Error(message) + } + } + + fun maxLength( + option: Int, + message: String = "Must not exceed $option characters.", + ): ValidationRule = ValidationRule { v -> + if (v == null) { + return@ValidationRule FormicaFieldResult.NoInput + } + if (v.count() <= option) { + FormicaFieldResult.Success + } else { + FormicaFieldResult.Error(message) + } + } + + fun range( + min: Float, + max: Float, + message: String = "Must be a number between $min and $max.", + ): ValidationRule = ValidationRule { v -> + if (v == null) { + return@ValidationRule FormicaFieldResult.NoInput + } + + if ((v >= min) && (v <= max)) { + FormicaFieldResult.Success + } else { + FormicaFieldResult.Error(message) + } + } + + fun range( + min: Int, + max: Int, + message: String = "Must be a number between $min and $max.", + ): ValidationRule = ValidationRule { v -> + if (v == null) { + return@ValidationRule FormicaFieldResult.NoInput + } + + if ((v >= min) && (v <= max)) { + FormicaFieldResult.Success + } else { + FormicaFieldResult.Error(message) + } + } +} + +private const val EMAIL_PATTERN = "[a-zA-Z0-9+._%\\-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" + + "(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+" + +private const val DOMAIN_URL_PATTERN = + "^[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$" +private const val HTTP_URL_PATTERN = + "^https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$" diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rememberFormica.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rememberFormica.kt deleted file mode 100644 index 3e2c1d7..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rememberFormica.kt +++ /dev/null @@ -1,19 +0,0 @@ -package dev.voir.formica - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember - -@Composable -fun rememberFormica(initialData: Data, onSubmit: ((Data) -> Unit)? = null) = remember { - Formica( - initialData = initialData, - onSubmit = onSubmit - ) -} - -@Composable -fun Formica.collectDataAsState() = this.data.collectAsState() - -@Composable -fun Formica.collectResultAsState() = this.result.collectAsState() diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/EmailRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/EmailRule.kt deleted file mode 100644 index b4df5c7..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/EmailRule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package dev.voir.formica.rules - -import dev.voir.formica.FormicaFieldResult - -class EmailRule( - private val message: String? = null -) : ValidationRule { - override fun validate(value: String?): FormicaFieldResult { - if (value == null) return FormicaFieldResult.NoInput - - return if (value.matches(EMAIL_PATTERN.toRegex())) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message ?: "Must be a valid email address.") - } - } - - companion object { - private const val EMAIL_PATTERN = - "[a-zA-Z0-9+._%\\-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" + - "(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+" - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/FloatRangeRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/FloatRangeRule.kt deleted file mode 100644 index b50c678..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/FloatRangeRule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package dev.voir.formica.rules - -import dev.voir.formica.FormicaFieldResult - -class FloatRangeRule( - private val min: Float, - private val max: Float, - private val message: String? = null -) : ValidationRule { - override fun validate(value: Float?): FormicaFieldResult { - if (value == null) return FormicaFieldResult.NoInput - - return if ((value >= min) && (value <= max)) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message ?: "Must be a number between $min and $max.") - } - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/IntegerRangeRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/IntegerRangeRule.kt deleted file mode 100644 index d95aefd..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/IntegerRangeRule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package dev.voir.formica.rules - -import dev.voir.formica.FormicaFieldResult - -class IntegerRangeRule( - private val min: Int, - private val max: Int, - private val message: String? = null -) : ValidationRule { - override fun validate(value: Int?): FormicaFieldResult { - if (value == null) return FormicaFieldResult.NoInput - - return if ((value >= min) && (value <= max)) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message ?: "Must be a number between $min and $max.") - } - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/IsCheckedRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/IsCheckedRule.kt deleted file mode 100644 index 3284349..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/IsCheckedRule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.voir.formica.rules - -import dev.voir.formica.FormicaFieldResult - -class IsCheckedRule( - private val message: String? = null -) : ValidationRule { - override fun validate(value: Boolean?): FormicaFieldResult { - if (value == null) return FormicaFieldResult.NoInput - - return if (value) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message ?: "Must be checked") - } - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/MaxLenghtRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/MaxLenghtRule.kt deleted file mode 100644 index d2c3172..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/MaxLenghtRule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.voir.formica.rules - -import dev.voir.formica.FormicaFieldResult - -class MaxLengthRule( - private val option: Int, - private val message: String? = null -) : ValidationRule { - override fun validate(value: String?): FormicaFieldResult { - if (value == null) return FormicaFieldResult.NoInput - - return if (value.count() <= option) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message ?: "Must not exceed $option characters.") - } - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/MinLenghtRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/MinLenghtRule.kt deleted file mode 100644 index b2b53d8..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/MinLenghtRule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.voir.formica.rules - -import dev.voir.formica.FormicaFieldResult - -class MinLengthRule( - private val option: Int, - private val message: String? = null -) : ValidationRule { - override fun validate(value: String?): FormicaFieldResult { - if (value == null) return FormicaFieldResult.NoInput - - return if (value.count() >= option) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message ?: "Must be at least ${this.option} characters long.") - } - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/NotEmptyRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/NotEmptyRule.kt deleted file mode 100644 index 7b7ef7f..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/NotEmptyRule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.voir.formica.rules - -import dev.voir.formica.FormicaFieldResult - -class NotEmptyRule( - private val message: String? = null -) : ValidationRule { - override fun validate(value: String?): FormicaFieldResult { - if (value == null) return FormicaFieldResult.NoInput - - return if (value.isNotEmpty()) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message ?: "This field cannot be empty.") - } - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/StrongPasswordRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/StrongPasswordRule.kt deleted file mode 100644 index 1e85c3b..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/StrongPasswordRule.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.voir.formica.rules - -import dev.voir.formica.FormicaFieldResult - -class StrongPasswordRule( - private val minLength: Int = 8, - private val lengthMessage: String? = null, - private val uppercaseMessage: String? = null, - private val lowercaseMessage: String? = null, - private val digitMessage: String? = null, - private val specialCharacterMessage: String? = null, -) : ValidationRule { - override fun validate(value: String?): FormicaFieldResult { - if (value == null) return FormicaFieldResult.NoInput - - return when { - value.length < minLength -> FormicaFieldResult.Error( - lengthMessage ?: "Password must be at least $minLength characters long." - ) - - !value.any { it.isUpperCase() } -> FormicaFieldResult.Error( - uppercaseMessage ?: "Password must contain at least one uppercase letter." - ) - - !value.any { it.isLowerCase() } -> FormicaFieldResult.Error( - lowercaseMessage ?: "Password must contain at least one lowercase letter." - ) - - !value.any { it.isDigit() } -> FormicaFieldResult.Error( - digitMessage ?: "Password must contain at least one digit." - ) - - !value.any { it in "!@#$%^&*()-_=+[]{};:'\",.<>?/|\\`~" } -> FormicaFieldResult.Error( - specialCharacterMessage ?: "Password must contain at least one special character." - ) - - else -> FormicaFieldResult.Success - } - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/URLRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/URLRule.kt deleted file mode 100644 index 115467e..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/URLRule.kt +++ /dev/null @@ -1,30 +0,0 @@ -package dev.voir.formica.rules - -import dev.voir.formica.FormicaFieldResult - -class WebUrlRule( - private val protocolRequired: Boolean = false, - private val message: String? = null -) : ValidationRule { - override fun validate(value: String?): FormicaFieldResult { - if (value == null) return FormicaFieldResult.NoInput - - val result = if (protocolRequired) { - value.matches(HTTP_URL_PATTERN.toRegex()) - } else { - value.matches(HTTP_URL_PATTERN.toRegex()) || value.matches(DOMAIN_URL_PATTERN.toRegex()) - } - return if (result) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message ?: "Must be a valid URL.") - } - } - - companion object { - private const val DOMAIN_URL_PATTERN = - "^[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$" - private const val HTTP_URL_PATTERN = - "^https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$" - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/ValidationRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/ValidationRule.kt deleted file mode 100644 index bc6f386..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/ValidationRule.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.voir.formica.rules - -import dev.voir.formica.FormicaFieldResult - -interface ValidationRule { - fun validate(value: V): FormicaFieldResult -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaFieldScope.kt b/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaFieldScope.kt deleted file mode 100644 index 39dd957..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaFieldScope.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.voir.formica.scopes - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState -import dev.voir.formica.FormicaField - -class FormicaFieldScope( - private val formField: FormicaField, -) { - val field: State - @Composable - get() = this.formField.value.collectAsState() - - val error: State - @Composable - get() = this.formField.error.collectAsState() -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaScope.kt b/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaScope.kt deleted file mode 100644 index e9c6fd8..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaScope.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.voir.formica.scopes - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState -import dev.voir.formica.Formica -import dev.voir.formica.FormicaField -import dev.voir.formica.FormicaFieldResult -import dev.voir.formica.FormicaResult -import dev.voir.formica.rules.ValidationRule -import kotlin.reflect.KMutableProperty1 - -class FormicaScope(private val formica: Formica) { - val data: State - @Composable - get() = formica.data.collectAsState(initial = formica.initialData) - - val result: State - @Composable - get() = formica.result.collectAsState(initial = FormicaResult.NoInput) - - fun validate() = formica.validate() - - fun submit() = formica.submit() - - fun onChange(fieldName: KMutableProperty1, value: Value) = - formica.onChange(fieldName, value) - - - fun registerField( - name: KMutableProperty1, - validators: Set>, - required: Boolean, - requiredError: String? = null, - customValidation: ((Value?) -> FormicaFieldResult)? = null, - validateOnChange: Boolean = true, - ): FormicaField = - formica.registerField( - name = name, - required = required, - requiredError = requiredError, - validators = validators, - customValidation = customValidation, - validateOnChange = validateOnChange - ) -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/Formica.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/Formica.kt deleted file mode 100644 index dcf8a44..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/ui/Formica.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.voir.formica.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.remember -import dev.voir.formica.Formica -import dev.voir.formica.scopes.FormicaScope - -@Composable -fun Formica( - formica: Formica, - content: @Composable FormicaScope.() -> Unit -) { - val scope = remember { - derivedStateOf { FormicaScope(formica = formica) } - } - scope.value.content() -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt index e5b21f4..13a6873 100644 --- a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt +++ b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt @@ -1,38 +1,175 @@ package dev.voir.formica.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import dev.voir.formica.Formica +import dev.voir.formica.FormicaFieldId import dev.voir.formica.FormicaFieldResult -import dev.voir.formica.rules.ValidationRule -import dev.voir.formica.scopes.FormicaFieldScope -import dev.voir.formica.scopes.FormicaScope -import kotlin.reflect.KMutableProperty1 +import dev.voir.formica.ValidationRule +/** + * A stable snapshot of a form field's UI-facing state and operations. + * + * This is what you pass into your composable input components so they have: + * - [value] → the current field value (nullable to represent "empty") + * - [error] → the last validation error message, or null if valid + * - [touched] → whether the user has interacted with the field yet + * - [dirty] → whether the value has changed from its initial value + * - [onChange] → callback to update the value (also updates form data snapshot) + * - [validate] → function to trigger validation manually; returns true if valid + * + * Marked as @Stable so Compose can optimize recompositions when only internal + * values change. + */ +@Stable +data class FieldAdapter( + val value: V?, + val error: String?, + val touched: Boolean, + val dirty: Boolean, + val onChange: (V?) -> Unit, + val validate: () -> Boolean +) + + +/** + * Register a form field with the given [Formica] instance and expose it to UI + * via a [FieldAdapter] in a type-safe, reactive way. + * + * This overload is for when you have an explicit [form] reference. + * + * Typical usage in Compose: + * ``` + * FormicaField(form, FirstName, validators = setOf(...)) { f -> + * BasicTextField( + * value = f.value ?: "", + * onValueChange = { f.onChange(it) } + * ) + * f.error?.let { Text(it, color = Color.Red) } + * } + * ``` + * + * @param form The form instance holding all field state and data snapshot. + * @param id A [FormicaFieldId] lens for reading/writing this field in the form's data. + * @param validators Optional set of ordered validation rules for this field. + * @param customValidation Optional extra validation run after [validators]. + * @param validateOnChange Whether to run validation automatically on every value change. + * @param content Composable content lambda that receives a [FieldAdapter] for UI binding. + */ @Composable -fun FormicaScope.FormicaField( - name: KMutableProperty1, - required: Boolean = true, - requiredError: String? = null, - validators: Set> = emptySet(), - customValidation: ((Value?) -> FormicaFieldResult)? = null, +fun FormicaField( + form: Formica, + id: FormicaFieldId, + validators: Set> = emptySet(), + customValidation: ((V?) -> FormicaFieldResult)? = null, validateOnChange: Boolean = true, - content: @Composable FormicaFieldScope.() -> Unit + content: @Composable (FieldAdapter) -> Unit ) { - val scope = remember { - derivedStateOf { - FormicaFieldScope( - formField = registerField( - name = name, - required = required, - requiredError = requiredError, - validators = validators, - customValidation = customValidation, - validateOnChange = validateOnChange - ) - ) - } + // Register the field once for this form + id combination. + // Registration seeds its initial value from the current form data snapshot. + val field = remember(form, id) { + form.registerField( + id = id, + validators = validators, + customValidation = customValidation, + validateOnChange = validateOnChange + ) + } + + // Collect reactive field state for UI binding. + // These flows update whenever field state changes (value, error, touched, dirty). + val value by field.value.collectAsState(initial = id.get(form.data.value)) + val error by field.error.collectAsState(initial = null) + val touched by field.touched.collectAsState(initial = false) + val dirty by field.dirty.collectAsState(initial = false) + + // Optional: Keep field state in sync if form.data changes externally (e.g., loaded draft). + // This resets the field's internal state (value/error/touched/dirty) to match the new snapshot. + val dataSnapshot by form.data.collectAsState() + LaunchedEffect(dataSnapshot) { + field.reset(id.get(dataSnapshot)) } - scope.value.content() + // Package the reactive state and callbacks into a stable adapter for the UI. + val adapter = remember(value, error, touched, dirty) { + FieldAdapter( + value = value, + error = error, + touched = touched, + dirty = dirty, + onChange = { v -> form.onChange(id, v) }, + validate = { field.isValid() } + ) + } + + // Render UI content with the adapter. + content(adapter) +} + +/** + * Overload of [FormicaField] that uses the ambient [LocalFormica] form context + * instead of requiring an explicit [form] parameter. + * + * Allows cleaner usage when you've wrapped your UI in a FormicaProvider: + * ``` + * FormicaProvider(form) { + * FormicaField(FirstName) { f -> + * BasicTextField( + * value = f.value ?: "", + * onValueChange = { f.onChange(it) } + * ) + * } + * } + * ``` + */ +@Composable +fun FormicaField( + id: FormicaFieldId, + validators: Set> = emptySet(), + customValidation: ((V?) -> FormicaFieldResult)? = null, + validateOnChange: Boolean = true, + content: @Composable (FieldAdapter) -> Unit +) { + val form = formicaOf() // Grab the current form from the composition + FormicaField( + form = form, + id = id, + validators = validators, + customValidation = customValidation, + validateOnChange = validateOnChange, + content = content + ) +} + +/** + * Convenience for reading a field's committed value from a [Formica] instance + * reactively (without registering a field). + * + * Returns the current value of the field from the immutable form data snapshot. + * Will recompose whenever [form.data] changes. + */ +@Composable +fun rememberFormicaFieldValue(form: Formica, id: FormicaFieldId): V? { + val data by form.data.collectAsState() + return id.get(data) +} + +/** + * Overload of [rememberFormicaFieldValue] that uses [LocalFormica] instead of + * requiring an explicit [form] parameter. + * + * Useful inside a [FormicaProvider] scope: + * ``` + * val firstName = rememberFormicaFieldValue(FirstName) ?: "" + * ``` + */ +@Composable +fun rememberFormicaFieldValue(id: FormicaFieldId): V? { + val form = formicaOf() + val data by form.data.collectAsState() + return id.get(data) } diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldPresence.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldPresence.kt new file mode 100644 index 0000000..2bb3fa9 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldPresence.kt @@ -0,0 +1,76 @@ +package dev.voir.formica.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import dev.voir.formica.Formica +import dev.voir.formica.FormicaFieldId + +/** + * Conditional wrapper for a form field that can be shown/hidden without losing registration. + * + * This is useful for *conditionally rendered fields* where: + * - You still want the field to be part of the form's registry (state preserved across shows/hides) + * - You want to enable/disable validation automatically based on visibility + * - You optionally want to clear its value when it is hidden + * + * ### How it works + * - Looks up the registered [FormicaField] for [id] (does **not** register it — you must have + * registered it beforehand via `FormicaField` or `registerField`). + * - Whenever [present] changes: + * - Calls `field.setEnabled(present)` so validation short-circuits when hidden. + * - If `present == false` and [clearOnHide] is `true`, also calls `form.onChange(id, null)` + * to clear the field value in both field state and the form data snapshot. + * - Renders [content] only if [present] is `true`. + * + * ### Example: + * ``` + * // Always register the field + * FormicaField(form, AdditionalText) { adapter -> + * TextField( + * value = adapter.value.orEmpty(), + * onValueChange = adapter.onChange + * ) + * } + * + * // Conditionally render it + * FormFieldPresence( + * form = form, + * id = AdditionalText, + * present = isExtraSectionEnabled, + * clearOnHide = true + * ) { + * // UI for AdditionalText here + * } + * ``` + * + * @param form The form instance holding the registered field. + * @param id The lens identifying the field to control. + * @param present Whether the field should be visible and validated. + * @param clearOnHide If true, clears the field value when it becomes hidden. + * @param content UI content to render when the field is present. + */ +@Composable +fun FormFieldPresence( + form: Formica, + id: FormicaFieldId, + present: Boolean, + clearOnHide: Boolean = false, + content: @Composable () -> Unit +) { + // Cache the registered field instance for the lifetime of this form+id combination. + // This avoids re-fetching the field every recomposition. + val field = remember(form, id) { form.getRegisteredField(id) } + + // React to changes in the `present` flag. + LaunchedEffect(present) { + field?.setEnabled(present) // Enable/disable validation + if (!present && clearOnHide) { + // Clear value in both field state and form data snapshot + form.onChange(id, null) + } + } + + // Render UI only when field is "present". + if (present) content() +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldState.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldState.kt new file mode 100644 index 0000000..e1493b8 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldState.kt @@ -0,0 +1,106 @@ +package dev.voir.formica.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import dev.voir.formica.Formica +import dev.voir.formica.FormicaFieldId + +/** + * A stable snapshot of a registered field's full reactive state and actions. + * + * This is very similar to [FieldAdapter], but intended for cases where you want + * to **access a field's state outside of its own Composable input**, for example: + * + * - To conditionally render another UI element based on the field's value or error + * - To build composite components that depend on multiple fields + * - To trigger validation from somewhere else in the UI + * + * @param value Current value of the field (nullable to represent "empty"). + * @param error Current error message if invalid, or `null` if valid. + * @param touched Whether the field has been interacted with at least once. + * @param dirty Whether the value has changed from its initial value. + * @param onChange Callback to update the field's value in both field state and form data. + * @param validate Triggers validation immediately; returns `true` if valid. + * + * Marked @Stable so Compose can optimize recomposition. + */ +@Stable +data class FormicaFieldState( + val value: V?, + val error: String?, + val touched: Boolean, + val dirty: Boolean, + val onChange: (V?) -> Unit, + val validate: () -> Boolean +) + +/** + * Retrieve a [FormicaFieldState] for a previously registered field in [form], + * observing all of its reactive properties (value, error, touched, dirty). + * + * @param form The [Formica] instance that holds the registered field. + * @param id The [FormicaFieldId] lens identifying the field. + * + * @return A [FormicaFieldState] bound to this field, or `null` if the field + * has not been registered in the form. + * + * ### Example + * ``` + * val fieldState = rememberFormicaFieldState(form, Email) + * if (fieldState?.error != null) { + * Text("Invalid email", color = Color.Red) + * } + * ``` + * + * **Important:** This does *not* register the field. You must register it + * beforehand with `FormicaField(...)` or `form.registerField(...)`. + */ +@Composable +fun rememberFormicaFieldState( + form: Formica, + id: FormicaFieldId +): FormicaFieldState? { + // Remember the registered field instance so we don't look it up every recomposition + val field = remember(form, id) { form.getRegisteredField(id) } ?: return null + + // Observe reactive properties from the field's StateFlows + val value by field.value.collectAsState() + val error by field.error.collectAsState() + val touched by field.touched.collectAsState() + val dirty by field.dirty.collectAsState() + + // Package everything into a stable snapshot object + return remember(value, error, touched, dirty) { + FormicaFieldState( + value = value, + error = error, + touched = touched, + dirty = dirty, + onChange = { form.onChange(id, it) }, + validate = { field.isValid() } + ) + } +} + +/** + * Overload of [rememberFormicaFieldState] that retrieves the current [Formica] + * instance from [LocalFormica], so you don't need to pass `form` manually. + * + * Use inside a [FormicaProvider] scope: + * ``` + * val fieldState = rememberFormicaFieldState(Email) + * if (fieldState?.dirty == true) { + * SaveButton() + * } + * ``` + */ +@Composable +fun rememberFormicaFieldState( + id: FormicaFieldId +): FormicaFieldState? { + val form = formicaOf() + return rememberFormicaFieldState(form, id) +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaProvider.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaProvider.kt new file mode 100644 index 0000000..00dcc98 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaProvider.kt @@ -0,0 +1,99 @@ +package dev.voir.formica.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import dev.voir.formica.Formica + +/** + * A CompositionLocal that holds the current [Formica] instance for this UI tree. + * + * - Typed as `Formica?` internally so it can store any generic form type. + * - Accessed via [formicaOf] to get a strongly-typed instance. + * - Provided by [FormicaProvider]. + * + * Defaults to `null` when not inside a [FormicaProvider] scope. + */ +val LocalFormica = staticCompositionLocalOf?> { null } + +/** + * Create and remember a [Formica] instance tied to the current composition. + * + * This is typically called at the top of a screen or form scope. + * The instance will survive recompositions, and will only be recreated when + * either [initialData] or [onSubmit] changes. + * + * @param initialData The initial immutable data model for the form. + * @param onSubmit Optional callback invoked with the form's current data when [Formica.submit] is called. + * + * @return A remembered [Formica] instance you can use directly or pass to [FormicaProvider]. + * + * ### Example: + * ``` + * val form = rememberFormica(Profile("", null)) { data -> + * saveProfile(data) + * } + * ``` + */ +@Composable +fun rememberFormica( + initialData: Data, + onSubmit: ((Data) -> Unit)? = null +): Formica { + // Will only recreate the Formica instance when initialData or onSubmit changes + return remember(initialData, onSubmit) { + Formica(initialData, onSubmit) + } +} + +/** + * Provide a [Formica] instance to all composables in [content] via [LocalFormica]. + * + * This allows child composables to use [formicaOf] to retrieve the form without + * having to pass it down explicitly. + * + * @param form The form instance to provide in this composition scope. + * @param content UI content that should have access to [form] via [LocalFormica]. + * + * ### Example: + * ``` + * val form = rememberFormica(Profile("", null)) + * FormicaProvider(form) { + * // Inside here, you can call formicaOf() to get the form + * } + * ``` + */ +@Composable +fun FormicaProvider( + form: Formica, + content: @Composable () -> Unit +) { + @Suppress("UNCHECKED_CAST") // Safe cast because we only ever read via formicaOf() + CompositionLocalProvider(LocalFormica provides (form as Formica)) { + content() + } +} + +/** + * Retrieve the current [Formica] instance from [LocalFormica] with strong typing. + * + * This must be called inside a [FormicaProvider] scope, otherwise it will throw an error. + * + * @return The current [Formica] instance typed as [Formica]. + * + * ### Example: + * ``` + * val form = formicaOf() + * form.onChange(FirstName, "Gary") + * ``` + * + * @throws IllegalStateException if no [FormicaProvider] is found in the current composition. + */ +@Suppress("UNCHECKED_CAST") +@Composable +fun formicaOf(): Formica { + val f = LocalFormica.current + ?: error("No Formica in scope. Wrap with FormicaProvider or pass form explicitly.") + return f as Formica +} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/FormicFieldTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/FormicFieldTest.kt deleted file mode 100644 index 13f0c0c..0000000 --- a/formica/src/commonTest/kotlin/dev/voir/formica/FormicFieldTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -package dev.voir.formica - -import dev.voir.formica.rules.ValidationRule -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class FormicaFieldTest { - - @Test - fun `initial state is correct`() = runTest { - val field = FormicaField(initialValue = null, required = false) - assertNull(field.value.first()) - assertNull(field.error.first()) - } - - @Test - fun `onChange updates value and validates when validateOnChange is true`() = runTest { - val field = FormicaField( - initialValue = null, - required = true, - requiredError = "Required field" - ) - - field.onChange("New Value") - - assertEquals("New Value", field.value.first()) - assertNull(field.error.first()) - } - - @Test - fun `onChange skips validation when validateOnChange is false`() = runTest { - val field = FormicaField( - initialValue = null, - required = true, - validateOnChange = false, - requiredError = "Required field" - ) - - field.onChange(null) - - assertNull(field.error.first()) // No validation should have occurred - } - - @Test - fun `validate returns error for required field with null value`() = runTest { - val field = FormicaField( - initialValue = null, - required = true, - requiredError = "Field is required" - ) - - assertFalse(field.isValid()) - assertEquals("Field is required", field.error.first()) - } - - @Test - fun `validate passes for non-required field with null value`() = runTest { - val field = FormicaField( - initialValue = null, - required = false - ) - - assertTrue(field.isValid()) - assertNull(field.error.first()) - } - - @Test - fun `customValidation is applied correctly`() = runTest { - val customValidation: (String?) -> FormicaFieldResult = { input -> - if (input == "valid") FormicaFieldResult.Success - else FormicaFieldResult.Error("Invalid input") - } - - val field = FormicaField( - initialValue = null, - required = false, - customValidation = customValidation - ) - - field.onChange("invalid") - assertEquals("Invalid input", field.error.first()) - assertFalse(field.isValid()) - - field.onChange("valid") - assertNull(field.error.first()) - assertTrue(field.isValid()) - } - - @Test - fun `validators are applied correctly`() = runTest { - val rule: ValidationRule = object : ValidationRule { - override fun validate(value: String): FormicaFieldResult { - return if (value.length > 5) FormicaFieldResult.Success - else FormicaFieldResult.Error("Too short") - } - } - - val field = FormicaField( - initialValue = "", - required = false, - validators = setOf(rule) - ) - - field.onChange("short") - assertEquals("Too short", field.error.first()) - assertFalse(field.isValid()) - - field.onChange("longer text") - assertNull(field.error.first()) - assertTrue(field.isValid()) - } -} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/FormicaFieldTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/FormicaFieldTest.kt new file mode 100644 index 0000000..8c9ac60 --- /dev/null +++ b/formica/src/commonTest/kotlin/dev/voir/formica/FormicaFieldTest.kt @@ -0,0 +1,224 @@ +package dev.voir.formica + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FormicaFieldTest { + + // --- helpers ------------------------------------------------------------- + + private fun success() = FormicaFieldResult.Success + private fun err(msg: String) = FormicaFieldResult.Error(msg) + + private fun rule(block: (V?) -> FormicaFieldResult): ValidationRule = + ValidationRule { v -> block(v) } + + // Kotlin's setOf(...) is insertion-ordered (LinkedHashSet), so we can verify short-circuit. + private fun orderedRules(vararg rules: ValidationRule): Set> = + linkedSetOf(*rules) + + // --- initial state ------------------------------------------------------- + + @Test + fun initial_state_is_pristine() { + val f = FormicaField( + initialValue = "foo" + ) + + assertEquals("foo", f.value.value) + assertNull(f.error.value) + assertTrue(f.result.value is FormicaFieldResult.NoInput) + assertFalse(f.touched.value) + assertFalse(f.dirty.value) + } + + // --- onChange / touched / dirty ----------------------------------------- + + @Test + fun onChange_sets_value_and_flags() { + val f = FormicaField(initialValue = "a", validateOnChange = false) + + f.onChange("a") // same as initial + assertEquals("a", f.value.value) + assertTrue(f.touched.value) + assertFalse(f.dirty.value) + + f.onChange("b") // different + assertEquals("b", f.value.value) + assertTrue(f.touched.value) + assertTrue(f.dirty.value) + } + + // --- validateOnChange behaviour ----------------------------------------- + + @Test + fun validateOnChange_false_does_not_validate_automatically() { + val f = FormicaField( + initialValue = "", + validators = orderedRules(rule { if (it.isNullOrBlank()) err("x") else success() }), + validateOnChange = false + ) + + // After change, still NoInput because we didn't call validate() + f.onChange("") + assertTrue(f.result.value is FormicaFieldResult.NoInput) + assertNull(f.error.value) + + // Now validate explicitly + val r = f.validate() + assertTrue(r is FormicaFieldResult.Error) + assertEquals("x", f.error.value) + } + + @Test + fun validateOnChange_true_validates_automatically() { + val f = FormicaField( + initialValue = "", + validators = orderedRules(rule { if (it.isNullOrBlank()) err("empty") else success() }), + validateOnChange = true + ) + + f.onChange("") // triggers validate + assertEquals("empty", f.error.value) + assertTrue(f.result.value is FormicaFieldResult.Error) + + f.onChange("ok") // triggers validate + assertNull(f.error.value) + assertTrue(f.result.value is FormicaFieldResult.Success) + } + + // --- validators before custom & short-circuit ---------------------------- + + @Test + fun validators_run_before_custom_and_shortCircuit_on_error() { + var customCalled = false + + val v1 = rule { err("fail-1") } // first fails + val v2 = rule { error("should not run") } // must never run + val custom = { _: String? -> + customCalled = true + success() + } + + val f = FormicaField( + initialValue = "x", + validators = orderedRules(v1, v2), + customValidation = custom, + validateOnChange = false + ) + + val r = f.validate() + assertTrue(r is FormicaFieldResult.Error) + assertEquals("fail-1", f.error.value) + assertFalse(customCalled) // custom not called due to short-circuit + } + + @Test + fun custom_runs_when_validators_pass_and_can_fail() { + var vCalled = false + val vOk = rule { vCalled = true; success() } + val custom = { _: String? -> err("custom-fail") } + + val f = FormicaField( + initialValue = "x", + validators = orderedRules(vOk), + customValidation = custom, + validateOnChange = false + ) + + val r = f.validate() + assertTrue(vCalled) + assertTrue(r is FormicaFieldResult.Error) + assertEquals("custom-fail", f.error.value) + } + + @Test + fun success_sets_success_and_clears_error() { + val f = FormicaField( + initialValue = "x", + validators = orderedRules(rule { success() }), + customValidation = { success() }, + validateOnChange = false + ) + + val r = f.validate() + assertTrue(r is FormicaFieldResult.Success) + assertNull(f.error.value) + assertTrue(f.result.value is FormicaFieldResult.Success) + } + + // --- enabled / presence -------------------------------------------------- + + @Test + fun disabled_field_skips_validation_and_is_always_success() { + var vCalls = 0 + val v = rule { vCalls++; err("nope") } + + val f = FormicaField( + initialValue = "", + validators = orderedRules(v), + validateOnChange = true + ) + + // disable first + f.setEnabled(false) + + // onChange would normally validate, but since disabled, it should stay Success + f.onChange("") // validateOnChange triggers validate() + assertNull(f.error.value) + assertTrue(f.result.value is FormicaFieldResult.Success) + assertEquals(0, vCalls, "validator must not be called when disabled") + + // validate() should also short-circuit + val r = f.validate() + assertTrue(r is FormicaFieldResult.Success) + assertEquals(0, vCalls) + } + + // --- reset --------------------------------------------------------------- + + @Test + fun reset_restores_pristine_state_and_updates_initial() { + val f = FormicaField( + initialValue = "init", + validators = orderedRules(rule { if (it.isNullOrBlank()) err("empty") else success() }), + validateOnChange = true + ) + + f.onChange("") // set error, touched, dirty + assertTrue(f.touched.value) + assertTrue(f.dirty.value) + assertEquals("empty", f.error.value) + + f.reset("fresh") + assertEquals("fresh", f.value.value) + assertTrue(f.result.value is FormicaFieldResult.NoInput) + assertNull(f.error.value) + assertFalse(f.touched.value) + assertFalse(f.dirty.value) + + // dirty should reflect new initial + f.onChange("fresh") + assertFalse(f.dirty.value) + f.onChange("changed") + assertTrue(f.dirty.value) + } + + // --- isValid() ----------------------------------------------------------- + + @Test + fun isValid_calls_validate_and_returns_boolean() { + val f = FormicaField( + initialValue = "", + validators = orderedRules(rule { if (it.isNullOrBlank()) err("x") else success() }), + validateOnChange = false + ) + + assertFalse(f.isValid()) // triggers validate -> error + f.onChange("ok") + assertTrue(f.isValid()) + } +} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt index 7f4bc42..171a4e8 100644 --- a/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt +++ b/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt @@ -1,140 +1,238 @@ package dev.voir.formica -import dev.voir.formica.rules.ValidationRule -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue -data class TestData(var field1: String, var field2: Int) +// ---------- Fixtures --------------------------------------------------------- -class FormicaTest { +private data class Profile( + val firstName: String, + val note: String?, + val age: Int +) + +private val FirstName = FormicaFieldId( + id = "firstName", + get = { it.firstName }, + set = { d, v -> d.copy(firstName = v) } +) + +private val Note = FormicaFieldId( + id = "note", + get = { it.note }, + set = { d, v -> d.copy(note = v) }, + clear = { d -> d.copy(note = null) } +) + +private val Age = FormicaFieldId( + id = "age", + get = { it.age }, + set = { d, v -> d.copy(age = v) } + // no clear -> null updates should NOT change data snapshot +) + +// Helpers +private fun rule(block: (V?) -> FormicaFieldResult): ValidationRule = + ValidationRule { v -> block(v) } + +// Preserve validator order explicitly for Set +private fun ordered(vararg r: ValidationRule): Set> = linkedSetOf(*r) + +// ---------- Tests ------------------------------------------------------------ + +class FormicaCoreTest { @Test - fun `initial state is correct`() = runTest { - val testData = TestData(field1 = "initial", field2 = 0) - val formica = Formica(initialData = testData) + fun registerField_seeds_initialValue_from_data() { + val form = Formica(Profile("Ann", null, 21)) + val f = form.registerField( + id = FirstName, + validators = emptySet() + ) + assertEquals("Ann", f.value.value) + assertTrue(f.result.value is FormicaFieldResult.NoInput) + } + + @Test + fun onChange_updates_field_and_data_immutably() { + val form = Formica(Profile("Ann", null, 21)) + form.registerField(id = FirstName, validators = emptySet()) + + val before = form.data.value + form.onChange(FirstName, "Bob") - assertEquals(testData, formica.data.first()) - assertTrue(formica.result.first() is FormicaResult.NoInput) + val after = form.data.value + assertNotSame(before, after) // new snapshot + assertEquals("Bob", after.firstName) // changed + assertEquals("Ann", before.firstName) // old unchanged } @Test - fun `registerField adds field and allows validation`() = runTest { - val testData = TestData(field1 = "initial", field2 = 0) - val formica = Formica(initialData = testData) - - val rule: ValidationRule = object : ValidationRule { - override fun validate(value: String?): FormicaFieldResult { - return if (value.isNullOrEmpty()) FormicaFieldResult.Error("Field1 cannot be empty") - else FormicaFieldResult.Success - } - } - - val field = formica.registerField( - name = TestData::field1, - required = true, - validators = setOf(rule) - ) + fun onChange_null_uses_clear_when_available() { + val form = Formica(Profile("Ann", "hello", 21)) + val field = form.registerField(id = Note, validators = emptySet()) + + // sanity: both data and field start with "hello" + assertEquals("hello", form.data.value.note) + assertEquals("hello", field.value.value) + + form.onChange(Note, null) // should call FormicaFieldId.clear + assertNull(form.data.value.note) // data cleared + assertNull(field.value.value) // field value updated + } + + @Test + fun onChange_null_without_clear_does_not_mutate_data_snapshot() { + val form = Formica(Profile("Ann", "x", 30)) + val ageField = form.registerField(id = Age, validators = emptySet()) + + val before = form.data.value + assertEquals(30, before.age) - assertEquals("initial", field.value.first()) - assertTrue(field.isValid()) + form.onChange(Age, null) // no clear provided + + val after = form.data.value + assertSame(before, after) // same instance -> no change + assertEquals(30, after.age) + assertNull(ageField.value.value) // field state still updated to null } @Test - fun `onChange updates data and validates field`() = runTest { - val testData = TestData(field1 = "initial", field2 = 0) - val formica = Formica(initialData = testData) - - val rule: ValidationRule = object : ValidationRule { - override fun validate(value: String?): FormicaFieldResult { - return if (value.isNullOrEmpty()) FormicaFieldResult.Error("Field1 cannot be empty") - else FormicaFieldResult.Success - } - } - - formica.registerField( - name = TestData::field1, - required = true, - validators = setOf(rule) + fun getRegisteredField_returns_same_instance_and_can_toggle_enabled() { + val form = Formica(Profile("Ann", null, 21)) + form.registerField( + id = FirstName, + validators = ordered(rule { FormicaFieldResult.Error("fail") }), + validateOnChange = true ) - formica.onChange(TestData::field1, "updated") - assertEquals("updated", formica.data.first().field1) - assertEquals(FormicaResult.NoInput, formica.result.first()) + val f = form.getRegisteredField(FirstName) + assertNotNull(f) + // Disable -> validation should short-circuit to Success + f.setEnabled(false) + form.onChange(FirstName, "") // would fail if enabled + assertTrue(f.result.value is FormicaFieldResult.Success) + assertNull(f.error.value) } @Test - fun `validate updates state based on fields validity`() = runTest { - val testData = TestData(field1 = "valid", field2 = 0) - val formica = Formica(initialData = testData) - - val rule1: ValidationRule = object : ValidationRule { - override fun validate(value: String?): FormicaFieldResult { - return if (value.isNullOrEmpty()) FormicaFieldResult.Error("Field1 cannot be empty") - else FormicaFieldResult.Success - } - } - - val rule2: ValidationRule = object : ValidationRule { - override fun validate(value: Int?): FormicaFieldResult { - return if (value != null && value >= 0) FormicaFieldResult.Success - else FormicaFieldResult.Error("Field2 must be non-negative") - } - } - - formica.registerField( - name = TestData::field1, - required = true, - validators = setOf(rule1) + fun validate_aggregates_errors_with_fieldIds() { + val form = Formica(Profile("Ann", "", 5)) + form.registerField( + id = FirstName, + validators = ordered(rule { if (it.isNullOrBlank()) FormicaFieldResult.Error("first required") else FormicaFieldResult.Success }) + ) + form.registerField( + id = Note, + validators = ordered(rule { FormicaFieldResult.Success }) // note ok ) - formica.registerField( - name = TestData::field2, - required = true, - validators = setOf(rule2) + // Make firstName empty -> should produce an error map entry + form.onChange(FirstName, "") + val res = form.validate() + assertTrue(res is FormicaResult.Error) + val err = res as FormicaResult.Error + assertEquals(mapOf("firstName" to "first required"), err.fieldErrors) + } + + @Test + fun validator_order_shortCircuits_and_custom_runs_last() { + var v1Called = false + var v2Called = false + var customCalled = false + + val form = Formica(Profile("Ann", null, 21)) + form.registerField( + id = FirstName, + validators = ordered( + rule { v1Called = true; FormicaFieldResult.Error("v1") }, + rule { v2Called = true; error("should not be called") } + ), + customValidation = { customCalled = true; FormicaFieldResult.Success }, + validateOnChange = false ) - assertEquals(FormicaResult.Valid, formica.validate()) - formica.onChange(TestData::field1, "") - formica.validate() - assertTrue(formica.result.first() is FormicaResult.Error) + val res = form.validate() + assertTrue(res is FormicaResult.Error) + assertTrue(v1Called) + assertFalse(v2Called) // short-circuited + assertFalse(customCalled) // skipped because validator failed + } + + @Test + fun syncFromData_resets_fields_to_match_current_snapshot() { + val form = Formica(Profile("Ann", "hello", 21)) + val noteField = + form.registerField(id = Note, validators = emptySet(), validateOnChange = true) + + // Mutate field (and make it dirty) + form.onChange(Note, "changed") + assertEquals("changed", noteField.value.value) + assertTrue(noteField.dirty.value) + + // Mutate DATA without touching field state via clear() + form.clear(Note) + assertNull(form.data.value.note) + // Field value is still "changed" and dirty at this moment + assertEquals("changed", noteField.value.value) + + // Now sync field state from DATA snapshot + form.syncFromData() + assertNull(noteField.value.value) + assertTrue(noteField.result.value is FormicaFieldResult.NoInput) + assertFalse(noteField.dirty.value) + assertFalse(noteField.touched.value) } @Test - fun `validate returns error when fields are invalid`() = runTest { - val testData = TestData(field1 = "", field2 = -1) - val formica = Formica(initialData = testData) - - val rule1: ValidationRule = object : ValidationRule { - override fun validate(value: String?): FormicaFieldResult { - return if (value.isNullOrEmpty()) FormicaFieldResult.Error("Field1 cannot be empty") - else FormicaFieldResult.Success - } - } - - val rule2: ValidationRule = object : ValidationRule { - override fun validate(value: Int?): FormicaFieldResult { - return if (value != null && value >= 0) FormicaFieldResult.Success - else FormicaFieldResult.Error("Field2 must be non-negative") - } - } - - formica.registerField( - name = TestData::field1, - required = true, - validators = setOf(rule1) + fun submit_invokes_onSubmit_only_when_valid() { + var submitted: Profile? = null + val form = Formica( + initialData = Profile("Ann", null, 21), + onSubmit = { submitted = it } ) - formica.registerField( - name = TestData::field2, - required = true, - validators = setOf(rule2) + // FirstName required + form.registerField( + id = FirstName, + validators = ordered(rule { if (it.isNullOrBlank()) FormicaFieldResult.Error("required") else FormicaFieldResult.Success }) ) - val validationResult = formica.validate() - assertTrue(validationResult is FormicaResult.Error) - assertEquals("Some fields not valid", validationResult.message) + // Make invalid + form.onChange(FirstName, "") + val r1 = form.submit() + assertTrue(r1 is FormicaResult.Error) + assertNull(submitted) + + // Fix and submit again + form.onChange(FirstName, "Ok") + val r2 = form.submit() + assertTrue(r2 is FormicaResult.Valid) + assertNotNull(submitted) + assertEquals("Ok", submitted!!.firstName) + } + + @Test + fun clear_updates_data_only_and_leaves_field_state_as_is() { + val form = Formica(Profile("Ann", "keep", 21)) + val noteField = + form.registerField(id = Note, validators = emptySet(), validateOnChange = false) + + // Change field & data to "text" + form.onChange(Note, "text") + assertEquals("text", noteField.value.value) + assertEquals("text", form.data.value.note) + + // Clear via form.clear -> data changes, field state remains "text" + form.clear(Note) + assertNull(form.data.value.note) + assertEquals("text", noteField.value.value) // unchanged! + // Caller can optionally call noteField.reset(...) or form.syncFromData() } } diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/ValidationRuleTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/ValidationRuleTest.kt new file mode 100644 index 0000000..c1fe1b5 --- /dev/null +++ b/formica/src/commonTest/kotlin/dev/voir/formica/ValidationRuleTest.kt @@ -0,0 +1,209 @@ +package dev.voir.formica + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ValidationRulesTest { + + // ---- Helpers ------------------------------------------------------------ + + private fun assertSuccess(result: FormicaFieldResult) = + assertTrue(result is FormicaFieldResult.Success, "Expected Success, got $result") + + private fun assertErrorMessage(result: FormicaFieldResult, expected: String) { + when (result) { + is FormicaFieldResult.Error -> assertEquals(expected, result.message) + else -> throw AssertionError("Expected Error('$expected'), got $result") + } + } + + // ---- validateOnlyIf ----------------------------------------------------- + + @Test + fun validateOnlyIf_runsRuleWhenActive() { + var called = false + val rule = ValidationRules.validateOnlyIf(active = { true }) { v: String? -> + called = true + if (v.isNullOrBlank()) FormicaFieldResult.Error("X") else FormicaFieldResult.Success + } + + val r1 = rule.validate(null) + assertTrue(called) + assertErrorMessage(r1, "X") + + called = false + val r2 = rule.validate("ok") + assertTrue(called) + assertSuccess(r2) + } + + @Test + fun validateOnlyIf_skipsWhenInactive() { + var called = false + val rule = ValidationRules.validateOnlyIf(active = { false }) { _: String? -> + called = true + FormicaFieldResult.Error("should never be called") + } + + val r = rule.validate(null) + assertFalse(called) + assertSuccess(r) + } + + // ---- required ----------------------------------------------------------- + + @Test + fun required_handlesNullBlankAndEmpty() { + val ruleStr = ValidationRules.required() + assertErrorMessage(ruleStr.validate(null), "Field is required") + assertErrorMessage(ruleStr.validate(""), "Field is required") + assertErrorMessage(ruleStr.validate(" "), "Field is required") + assertSuccess(ruleStr.validate("data")) + + val ruleList = ValidationRules.required>() + assertErrorMessage(ruleList.validate(null), "Field is required") + assertErrorMessage(ruleList.validate(emptyList()), "Field is required") + assertSuccess(ruleList.validate(listOf(1))) + } + + @Test + fun required_customMessageAndIsEmpty() { + val rule = ValidationRules.required( + message = "Need a positive int", + isEmpty = { it == null || it!! <= 0 } + ) + assertErrorMessage(rule.validate(null), "Need a positive int") + assertErrorMessage(rule.validate(0), "Need a positive int") + assertSuccess(rule.validate(1)) + } + + // ---- notEmpty / notBlank ------------------------------------------------ + + @Test + fun notEmpty_works() { + val rule = ValidationRules.notEmpty("NE") + assertErrorMessage(rule.validate(""), "NE") + assertSuccess(rule.validate(" ")) + assertSuccess(rule.validate("x")) + } + + @Test + fun notBlank_works() { + val rule = ValidationRules.notBlank("NB") + assertErrorMessage(rule.validate(""), "NB") + assertErrorMessage(rule.validate(" "), "NB") + assertSuccess(rule.validate("x")) + assertSuccess(rule.validate(" x ")) + } + + // ---- email -------------------------------------------------------------- + + @Test + fun email_validAndInvalid() { + val rule = ValidationRules.email("Bad email") + + // Valid + assertSuccess(rule.validate("a@b.co")) + assertSuccess(rule.validate("first.last+tag@sub.domain.io")) + + // Invalid + assertErrorMessage(rule.validate("plainaddress"), "Bad email") + assertErrorMessage(rule.validate("a@b"), "Bad email") + assertErrorMessage(rule.validate("@nope.com"), "Bad email") + assertErrorMessage(rule.validate("a@b..com"), "Bad email") + } + + // ---- strongPassword ----------------------------------------------------- + + @Test + fun strongPassword_coversEachFailurePath() { + val rule = ValidationRules.strongPassword( + minLength = 8, + lengthMessage = "LEN", + uppercaseMessage = "UC", + lowercaseMessage = "LC", + digitMessage = "DG", + specialCharacterMessage = "SC" + ) + + assertErrorMessage(rule.validate("A1!a"), "LEN") // too short + assertErrorMessage(rule.validate("abcd123!"), "UC") // no uppercase + assertErrorMessage(rule.validate("ABCD123!"), "LC") // no lowercase + assertErrorMessage(rule.validate("Abcd!!!!"), "DG") // no digit + assertErrorMessage(rule.validate("Abcd1234"), "SC") // no special + + assertSuccess(rule.validate("Abcd123!")) // all good + } + + // ---- url ---------------------------------------------------------------- + + @Test + fun url_withOrWithoutProtocol() { + val anyUrl = ValidationRules.url(protocolRequired = false, message = "URL") + val protoOnly = ValidationRules.url(protocolRequired = true, message = "URL") + + // Valid without protocol + assertSuccess(anyUrl.validate("example.com")) + assertSuccess(anyUrl.validate("sub.domain.io/path?q=1")) + + // Valid with protocol + assertSuccess(anyUrl.validate("http://example.com")) + assertSuccess(anyUrl.validate("https://www.example.org/a/b?x=1#frag")) + assertSuccess(protoOnly.validate("https://example.com")) + + // Invalids + assertErrorMessage(protoOnly.validate("example.com"), "URL") + assertErrorMessage(anyUrl.validate("htp://broken.com"), "URL") + assertErrorMessage(anyUrl.validate("://nope.com"), "URL") + assertErrorMessage(anyUrl.validate(" "), "URL") + } + + // ---- checked ------------------------------------------------------------ + + @Test + fun checked_rule() { + val rule = ValidationRules.checked("Must be checked") + assertErrorMessage(rule.validate(false), "Must be checked") + assertSuccess(rule.validate(true)) + } + + // ---- minLength / maxLength --------------------------------------------- + + @Test + fun minLength_and_maxLength() { + val min3 = ValidationRules.minLength(3, message = "MIN3") + val max5 = ValidationRules.maxLength(5, message = "MAX5") + + assertErrorMessage(min3.validate("ab"), "MIN3") + assertSuccess(min3.validate("abc")) + assertSuccess(min3.validate("abcdef")) + + assertSuccess(max5.validate("abc")) + assertSuccess(max5.validate("abcde")) + assertErrorMessage(max5.validate("abcdef"), "MAX5") + } + + // ---- range (Int) & range (Float) --------------------------------------- + + @Test + fun range_int_inclusive() { + val r = ValidationRules.range(min = 2, max = 4, message = "RANGE") + assertErrorMessage(r.validate(1), "RANGE") + assertSuccess(r.validate(2)) // min boundary + assertSuccess(r.validate(3)) + assertSuccess(r.validate(4)) // max boundary + assertErrorMessage(r.validate(5), "RANGE") + } + + @Test + fun range_float_inclusive() { + val r = ValidationRules.range(min = 0.5f, max = 1.5f, message = "RANGEF") + assertErrorMessage(r.validate(0.49f), "RANGEF") + assertSuccess(r.validate(0.5f)) + assertSuccess(r.validate(1.0f)) + assertSuccess(r.validate(1.5f)) + assertErrorMessage(r.validate(1.5001f), "RANGEF") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8c4d4a5..3a21056 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] -agp = "8.7.3" -android-compileSdk = "35" +agp = "8.12.0" +android-compileSdk = "36" android-minSdk = "24" -android-targetSdk = "35" -compose-plugin = "1.8.1" # https://github.com/JetBrains/compose-multiplatform +android-targetSdk = "36" +compose-plugin = "1.8.2" # https://github.com/JetBrains/compose-multiplatform junit = "4.13.2" -kotlin = "2.1.21" # https://kotlinlang.org/docs/releases.html +kotlin = "2.2.0" # https://kotlinlang.org/docs/releases.html kotlin-coroutines = "1.10.2" # https://github.com/Kotlin/kotlinx.coroutines androidx-activityCompose = "1.10.1" # https://mvnrepository.com/artifact/androidx.activity/activity-compose diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df97d72..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt b/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt index 72d7e12..29208e1 100644 --- a/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt +++ b/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt @@ -7,9 +7,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.Checkbox import androidx.compose.material.Text +import androidx.compose.material.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,52 +22,95 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import dev.voir.formica.FormicaFieldId import dev.voir.formica.FormicaFieldResult import dev.voir.formica.FormicaResult -import dev.voir.formica.collectDataAsState -import dev.voir.formica.rememberFormica -import dev.voir.formica.rules.NotEmptyRule +import dev.voir.formica.ValidationRule +import dev.voir.formica.ValidationRules import dev.voir.formica.sample.ui.FormFieldWrapper -import dev.voir.formica.ui.Formica +import dev.voir.formica.ui.FormFieldPresence import dev.voir.formica.ui.FormicaField +import dev.voir.formica.ui.FormicaProvider +import dev.voir.formica.ui.rememberFormica +import dev.voir.formica.ui.rememberFormicaFieldValue data class FormSchema( var text: String, + var number: Int?, var optionalText: String?, var activateAdditionalText: Boolean, var additionalText: String? = null, ) +val MainText = FormicaFieldId( + id = "text", + get = { it.text }, + set = { d, v -> d.copy(text = v) } +) +val Number = FormicaFieldId( + id = "number", + get = { it.number }, + set = { d, v -> d.copy(number = v) } +) + +val OptionalText = FormicaFieldId( + id = "optionalText", + get = { it.optionalText }, + set = { d, v -> d.copy(optionalText = v) } +) + +val ActivateAdditionalText = FormicaFieldId( + id = "activateAdditionalText", + get = { it.activateAdditionalText }, + set = { d, v -> d.copy(activateAdditionalText = v) } +) + +val AdditionalText = FormicaFieldId( + id = "additionalText", + get = { it.additionalText }, + set = { d, v -> d.copy(additionalText = v) }, + clear = { d -> d.copy(additionalText = null) } +) + + @Composable fun App() { + val verticalScroll = rememberScrollState() + val formica = rememberFormica( initialData = FormSchema( text = "", + number = null, optionalText = null, activateAdditionalText = false, additionalText = null, ) ) - val formData by formica.collectDataAsState() - var formError by remember { mutableStateOf(null) } + var formError by remember { mutableStateOf(null) } var formResult by remember { mutableStateOf(null) } - Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) { - Formica(formica = formica) { + val isActive = rememberFormicaFieldValue(formica, ActivateAdditionalText) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 48.dp) + .verticalScroll(verticalScroll) + ) { + FormicaProvider(formica) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { FormicaField( - name = FormSchema::text, - required = true, - validators = setOf(NotEmptyRule()) - ) { + id = MainText, + validators = setOf(ValidationRules.required()) + ) { field -> FormFieldWrapper { - androidx.compose.material.TextField( + TextField( modifier = Modifier.fillMaxWidth(), - value = field.value!!, + value = field.value.orEmpty(), label = { Text("Required text") }, @@ -72,20 +118,21 @@ fun App() { Text("Some required text") }, onValueChange = { - onChange(FormSchema::text, it) + field.onChange(it) }, ) - error.value?.let { - Text(it, color = Color.Red) + + if (field.error != null) { + Text(field.error!!, color = Color.Red) } } } - FormicaField(name = FormSchema::optionalText, required = false) { + FormicaField(id = OptionalText) { field -> FormFieldWrapper { - androidx.compose.material.TextField( + TextField( modifier = Modifier.fillMaxWidth(), - value = field.value ?: "", + value = field.value.orEmpty(), label = { Text("Optional text") }, @@ -93,19 +140,49 @@ fun App() { Text("Some optional text") }, onValueChange = { - onChange(FormSchema::optionalText, it) - } + field.onChange(it) + }, ) - if (error.value != null) { - Text(error.value!!, color = Color.Red) + + if (field.error != null) { + Text(field.error!!, color = Color.Red) } } } + // Number (optional, but must be >= 0 if provided) FormicaField( - name = FormSchema::activateAdditionalText, - required = true, - ) { + id = Number, + validators = setOf( + ValidationRule { v -> + if (v == null) FormicaFieldResult.Success + else if (v < 0) FormicaFieldResult.Error("Number must be non-negative") + else FormicaFieldResult.Success + } + ) + ) { field -> + FormFieldWrapper { + TextField( + modifier = Modifier.fillMaxWidth(), + value = field.value?.toString().orEmpty(), + label = { + Text("Number text") + }, + placeholder = { + Text("1234") + }, + onValueChange = { s -> + field.onChange(s.toIntOrNull()) + } + ) + + if (field.error != null) { + Text(field.error!!, color = Color.Red) + } + } + } + + FormicaField(id = ActivateAdditionalText) { field -> Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -114,44 +191,49 @@ fun App() { Checkbox( checked = field.value ?: false, onCheckedChange = { - onChange(FormSchema::activateAdditionalText, it) + field.onChange(it) }) Text("Activate additional text?") } } - // if (form.value.activateAdditionalText) { FormicaField( - name = FormSchema::additionalText, - required = false, + id = AdditionalText, customValidation = { value -> - if (formData.activateAdditionalText && value.isNullOrBlank()) { + if (value.isNullOrBlank()) { FormicaFieldResult.Error(message = "Field is required") } else { FormicaFieldResult.Success } } - ) { - FormFieldWrapper { - androidx.compose.material.TextField( - modifier = Modifier.fillMaxWidth(), - value = field.value ?: "", - label = { - Text("Required text if checkbox activated") - }, - placeholder = { - Text("Some additional text") - }, - onValueChange = { - onChange(FormSchema::additionalText, it) - }, - ) - error.value?.let { - Text(it, color = Color.Red) + ) { field -> + FormFieldPresence( + form = formica, + id = AdditionalText, + present = isActive == true, + clearOnHide = true + ) { + FormFieldWrapper { + TextField( + modifier = Modifier.fillMaxWidth(), + value = field.value.orEmpty(), + label = { + Text("Required text if checkbox activated") + }, + placeholder = { + Text("Some additional text") + }, + onValueChange = { + field.onChange(it) + }, + ) + + if (field.error != null) { + Text(field.error!!, color = Color.Red) + } } } } - //} } } @@ -169,7 +251,7 @@ fun App() { formError = null formResult = formica.data.value } else if (state is FormicaResult.Error) { - formError = state.message + formError = state formResult = null } }) { @@ -179,7 +261,8 @@ fun App() { formError?.let { Column(modifier = Modifier.fillMaxWidth()) { Text("Form submit error") - Text(text = it, color = Color.Red) + Text(text = it.message, color = Color.Red) + Text(text = it.fieldErrors.toString(), color = Color.Red) } }