From a602b0307d8378b24f11603341666fb57d9d7c31 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 24 Jun 2026 23:39:03 +0300 Subject: [PATCH] refactor: use inline field errors instead of a dedicated error row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer versions of the Toolbox API support FieldModifier.LocalizableError, which attaches validation error message directly to the field it originates from rather than displaying it in a separate row below. This change adopts that API across the workspace filter header, the deployment URL step, and the token step. The previous approach used a ValidationErrorField that sat at the bottom of each panel, empty most of the time and only populated when something went wrong. Removing it makes each panel one row more compact in the common case. Errors now appear inline on the relevant field — the search bar, URL input, or token input — making the source of the problem immediately obvious without the user having to scan to a separate row. - resolves DEVEX-372 - resolves DEVEX-374 --- .../com/coder/toolbox/CoderRemoteProvider.kt | 2 +- .../coder/toolbox/views/DeploymentUrlStep.kt | 42 ++++++++++--------- .../coder/toolbox/views/NewEnvironmentPage.kt | 22 ++++++---- .../com/coder/toolbox/views/TokenStep.kt | 28 ++++++++----- 4 files changed, 54 insertions(+), 40 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index fe1ee4fa..c316b5fc 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -222,7 +222,7 @@ class CoderRemoteProvider( } throw ex } - coderHeaderPage.reportFilterError(null) + coderHeaderPage.resetError() return workspaces.flatMap { ws -> // Agents are not included in workspaces that are off // so fetch them separately. diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 71ce31ec..72325344 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -12,13 +12,14 @@ import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.coder.toolbox.views.state.WizardModel +import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.CheckboxField +import com.jetbrains.toolbox.api.ui.components.FieldModifier import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.LabelStyleType import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType -import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import kotlinx.coroutines.flow.update import okhttp3.HttpUrl.Companion.toHttpUrl import java.net.MalformedURLException @@ -31,7 +32,7 @@ private const val OAUTH2_SCOPE: String = /** * A page with a field for providing the Coder deployment URL. - *\ + * * Populates with the provided URL, at which point the user can accept or * enter their own. */ @@ -48,28 +49,20 @@ class DeploymentUrlStep( context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") ) - private val errorField = ValidationErrorField(context.i18n.pnotr("")) - override val panel: RowGroup get() { if (!context.settingsStore.disableSignatureVerification) { return RowGroup( RowGroup.RowField(urlField), RowGroup.RowField(emptyLine), - RowGroup.RowField(signatureFallbackStrategyField), - RowGroup.RowField(errorField) + RowGroup.RowField(signatureFallbackStrategyField) ) } - return RowGroup( - RowGroup.RowField(urlField), - RowGroup.RowField(errorField) - ) + return RowGroup(RowGroup.RowField(urlField)) } override fun onVisible() { - errorField.textState.update { - context.i18n.pnotr("") - } + resetError() urlField.contentState.update { context.deploymentUrl.toString() } @@ -83,14 +76,14 @@ class DeploymentUrlStep( context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) val rawUrl = urlField.contentState.value if (rawUrl.isBlank()) { - errorField.textState.update { context.i18n.ptrl("URL is required") } + reportError(context.i18n.ptrl("URL is required")) return false } try { model.url = validateRawUrl(rawUrl) } catch (e: MalformedURLException) { - context.logAndShowError("Error encountered while setting up Coder", "URL is invalid", e) + reportError(context.i18n.pnotr("URL is invalid: ${e.message}")) return false } @@ -104,11 +97,7 @@ class DeploymentUrlStep( model.oauthSession = handleOAuth2(rawUrl) return false } catch (e: Exception) { - context.logAndShowError( - "Error encountered while setting up Coder", - "Failed to authenticate with OAuth2: ${e.message}", - e - ) + reportError(context.i18n.pnotr("Failed to authenticate with OAuth2: ${e.message}")) return false } } @@ -190,4 +179,17 @@ class DeploymentUrlStep( override fun onBack() { // it's the first step. Can't go anywhere back from here } + + private fun reportError(message: LocalizableString?) { + if (message != null) { + context.logger.info(message.toString()) + urlField.modifiers.value = listOf(FieldModifier.LocalizableError(message)) + } else { + resetError() + } + } + + private fun resetError() { + urlField.modifiers.value = emptyList() + } } diff --git a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt index 6877d5e6..af881d76 100644 --- a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt @@ -10,11 +10,11 @@ import com.coder.toolbox.util.presetNameForQuery import com.coder.toolbox.util.withFilterTerm import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.ComboBoxField +import com.jetbrains.toolbox.api.ui.components.FieldModifier import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.UiField -import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds @@ -57,9 +56,6 @@ class NewEnvironmentPage( placeholder = context.i18n.pnotr("") ) - // Shows the server's validation message under the search bar; blank when there is no error. - private val errorField = ValidationErrorField(context.i18n.pnotr("")) - // Dropdown selections. An empty string means "no filter for this key" (the leading "All ..." option). private val filtersSelection = MutableStateFlow(DEFAULT_WORKSPACE_FILTER_QUERY.presetNameForQuery()) private val templateSelection = MutableStateFlow("") @@ -113,7 +109,7 @@ class NewEnvironmentPage( val workspaceSearchQuery: StateFlow = mutableWorkspaceSearchQuery override val fields: StateFlow> = - MutableStateFlow(listOf(workspaceSearchField, filterControlsGroup, errorField)) + MutableStateFlow(listOf(workspaceSearchField, filterControlsGroup)) override fun beforeShow() { syncJob?.cancel() @@ -174,7 +170,7 @@ class NewEnvironmentPage( fun resetFilter() { workspaceSearchField.contentState.value = DEFAULT_WORKSPACE_FILTER_QUERY mutableWorkspaceSearchQuery.value = DEFAULT_WORKSPACE_FILTER_QUERY - reportFilterError(null) + resetError() } /** @@ -185,7 +181,7 @@ class NewEnvironmentPage( fun setFilter(query: String) { workspaceSearchField.contentState.value = query mutableWorkspaceSearchQuery.value = query - reportFilterError(null) + resetError() } /** @@ -193,7 +189,15 @@ class NewEnvironmentPage( * null. Called by the provider when a workspace query is rejected or succeeds. */ fun reportFilterError(message: String?) { - errorField.textState.update { context.i18n.pnotr(message ?: "") } + if (message != null) { + workspaceSearchField.modifiers.value = listOf(FieldModifier.LocalizableError(context.i18n.pnotr(message))) + } else { + resetError() + } + } + + fun resetError() { + workspaceSearchField.modifiers.value = emptyList() } private fun setQuery(query: String) { diff --git a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt index e7c4faaa..902f14fd 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt @@ -3,11 +3,12 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.util.withPath import com.coder.toolbox.views.state.WizardModel +import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.ui.components.FieldModifier import com.jetbrains.toolbox.api.ui.components.LinkField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType -import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -23,18 +24,14 @@ class TokenStep( ) : WizardStep { private val tokenField = TextField(context.i18n.ptrl("Token"), "", TextType.Password) private val linkField = LinkField(context.i18n.ptrl("Get a token"), "") - private val errorField = ValidationErrorField(context.i18n.pnotr("")) override val panel: RowGroup = RowGroup( RowGroup.RowField(tokenField), RowGroup.RowField(linkField), - RowGroup.RowField(errorField) ) override fun onVisible() { - errorField.textState.update { - context.i18n.pnotr("") - } + resetError() model.url?.let { url -> tokenField.contentState.update { context.secrets.apiTokenFor(url) ?: "" @@ -43,9 +40,7 @@ class TokenStep( url.withPath("/login?redirect=%2Fcli-auth").toString() } } ?: run { - errorField.textState.update { - context.i18n.pnotr("URL not configure in the previous step. Please go back and provide a proper URL.") - } + reportError(context.i18n.pnotr("URL not configured in the previous step. Please go back and provide a proper URL.")) (linkField.urlState as MutableStateFlow).update { "" } @@ -56,7 +51,7 @@ class TokenStep( override suspend fun onNext(): Boolean { val token = tokenField.contentState.value if (token.isBlank()) { - errorField.textState.update { context.i18n.ptrl("Token is required") } + reportError(context.i18n.ptrl("Token is required")) return false } @@ -68,4 +63,17 @@ class TokenStep( override fun onBack() { model.goToPrevious() } + + private fun reportError(message: LocalizableString?) { + if (message != null) { + context.logger.info(message.toString()) + tokenField.modifiers.value = listOf(FieldModifier.LocalizableError(message)) + } else { + resetError() + } + } + + private fun resetError() { + tokenField.modifiers.value = emptyList() + } }