Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement handling of command rejections #86

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions client/src/main/kotlin/io/spine/chords/client/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ public interface EventSubscription<E: EventMessage> {
* by the implementation.
*/
public suspend fun awaitEvent(): E

/**
* A callback, which is invoked when the subscribed event is emitted.
*/
public var onEvent: ((E) -> Unit)?
}

/**
Expand Down
528 changes: 528 additions & 0 deletions client/src/main/kotlin/io/spine/chords/client/CommandLifecycle.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -233,13 +233,15 @@ public class DesktopClient(
fieldValue: Message
): EventSubscription<E> {
val eventReceival = CompletableFuture<E>()
val futureEventSubscription = FutureEventSubscription(eventReceival)
observeEvent(
event = event,
field = field,
fieldValue = fieldValue) { evt ->
eventReceival.complete(evt)
futureEventSubscription.onEvent?.invoke(evt)
}
return FutureEventSubscription(eventReceival)
return futureEventSubscription
}

/**
Expand Down Expand Up @@ -326,4 +328,6 @@ private class FutureEventSubscription<E: EventMessage>(
override suspend fun awaitEvent(): E {
return withTimeout(ReactionTimeoutMillis) { future.await() }
}

override var onEvent: ((E) -> Unit)? = null
}
117 changes: 11 additions & 106 deletions client/src/main/kotlin/io/spine/chords/client/form/CommandMessageForm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.spine.base.CommandMessage
import io.spine.base.EventMessage
import io.spine.chords.client.CommandPostingError
import io.spine.chords.client.EventSubscription
import io.spine.chords.client.appshell.client
import io.spine.chords.client.CommandLifecycle
import io.spine.chords.client.post
import io.spine.chords.core.ComponentProps
import io.spine.chords.core.appshell.app
import io.spine.chords.core.layout.MessageDialog.Companion.showMessage
import io.spine.chords.proto.form.FormPartScope
import io.spine.chords.proto.form.MessageForm
import io.spine.chords.proto.form.MessageFormSetupBase
Expand Down Expand Up @@ -301,10 +297,10 @@ public class CommandMessageForm<C : CommandMessage> : MessageForm<C>() {

/**
* A function, which, given a command message that is about to be posted,
* should subscribe to a respective event that is expected to arrive in
* response to handling that command.
* should provide the [CommandLifecycle] object that defines how the
* command's outcomes should be handled.
*/
public lateinit var eventSubscription: (C) -> EventSubscription<out EventMessage>
public lateinit var commandLifecycle: (C) -> CommandLifecycle<C>

/**
* A state, which reports whether the form is currently in progress of
Expand Down Expand Up @@ -341,10 +337,7 @@ public class CommandMessageForm<C : CommandMessage> : MessageForm<C>() {

override fun initialize() {
super.initialize()
check(this::eventSubscription.isInitialized) {
"CommandMessageForm's `eventSubscription` property must " +
"be specified."
}
requireProperty(this::commandLifecycle.isInitialized, "commandOutcomeHandler")
}

@Composable
Expand All @@ -365,14 +358,12 @@ public class CommandMessageForm<C : CommandMessage> : MessageForm<C>() {
* errors to be displayed.
* - Posts the command that was validated and built.
* - Awaits for an event that should arrive upon successful handling of
* the command, as defined by the [eventSubscription]
* the command, as defined by the [commandLifecycle]
* constructor's parameters.
* - If the event doesn't arrive in a predefined timeout that is considered
* an adequate delay from user's perspective, this method
* throws [TimeoutCancellationException].
*
* @param outcomeHandler Specifies the way that command outcome is
* handled, e.g. the way how unexpected outcomes are processed.
* @return `true` if the command was successfully built without any
* validation errors, and `false` if the command message could not be
* successfully built from the currently entered data (validation errors
Expand All @@ -383,9 +374,7 @@ public class CommandMessageForm<C : CommandMessage> : MessageForm<C>() {
* the [postCommand] invocation is still being handled (when [posting] is
* still `true`).
*/
public suspend fun postCommand(
outcomeHandler: CommandOutcomeHandler<C> = DefaultOutcomeHandler()
): Boolean {
public suspend fun postCommand(): Boolean {
if (posting) {
throw IllegalStateException("Cannot invoke `postCommand`, while" +
"waiting for handling the previously posted command.")
Expand All @@ -399,97 +388,13 @@ public class CommandMessageForm<C : CommandMessage> : MessageForm<C>() {
"CommandMessageForm's value should be not null since it was just " +
"checked to be valid within postCommand."
}
val subscription = eventSubscription(command)

val commandOutcomeHandler = commandLifecycle(command)
return try {
posting = true
try {
app.client.command(command)
val event = subscription.awaitEvent()
outcomeHandler.onEvent(event)
true
} catch (e: CommandPostingError) {
outcomeHandler.onPostingError(e)
false
}
} catch (
@Suppress(
// A timeout condition is handled by `outcomeHandler`.
"SwallowedException"
)
e: TimeoutCancellationException
) {
outcomeHandler.onTimeout(command)
false
command.post(commandOutcomeHandler)
} finally {
posting = false
}
}
}

/**
* An object, which is capable of handling different kinds of outcomes that can
* follow as a result of posting a command.
*/
public interface CommandOutcomeHandler<C : CommandMessage> {

/**
* Invoked upon receiving an event that has
* the command [C].
*/
public fun onEvent(event: EventMessage)

/**
* Invoked if no event has been received in response to the command [C]
* in a reasonable period of time defined by the implementation.
*/
public suspend fun onTimeout(command: C)

/**
* Invoked if an error has occurred during posting and acknowledging
* the command on the server.
*
* @param error The exception that signifies the error that has occurred.
* @see io.spine.chords.client.Client.command
*/
public suspend fun onPostingError(error: CommandPostingError)
}

/**
* A default implementation for [CommandOutcomeHandler], which is expected to
* be suitable in most typical cases.
*
* @param eventHandler An optional callback, which will be invoked when
* an event that is expected as an outcome of the command [C] is emitted.
* @param timeoutMessage A lambda, which, given a command that was posted with
* no subsequent response received in time, should provide a message that
* should be displayed to the user in this case. If not specified, a default
* message is displayed.
* @param postingErrorMessage A lambda, which, given a [CommandPostingError]
* that has occurred when posting the command, returns the text that should
* be displayed to the user.
*/
public class DefaultOutcomeHandler<C : CommandMessage>(
private val eventHandler: ((EventMessage) -> Unit)? = null,
private val timeoutMessage: ((C) -> String)? = null,
private val postingErrorMessage: ((CommandPostingError) -> String)? = null
) : CommandOutcomeHandler<C> {
override fun onEvent(event: EventMessage) {
eventHandler?.invoke(event)
}

override suspend fun onTimeout(command: C) {
val message = timeoutMessage?.invoke(command) ?: (
"Timed out waiting for an event in response to " +
"the command ${command.javaClass.simpleName}"
)
showMessage(message)
}

override suspend fun onPostingError(error: CommandPostingError) {
val message = postingErrorMessage?.invoke(error) ?: (
"An error has occurred when posting or acknowledging " +
"the command ${error.message}"
)
showMessage(message)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import io.spine.base.CommandMessage
import io.spine.base.EventMessage
import io.spine.chords.client.EventSubscription
import io.spine.chords.client.CommandLifecycle
import io.spine.chords.client.form.CommandMessageForm
import io.spine.chords.core.layout.Dialog
import io.spine.chords.core.layout.SubmitOrCancelDialog
Expand Down Expand Up @@ -70,7 +69,7 @@ public abstract class CommandDialog<C : CommandMessage, B : ValidatingBuilder<C>
onBeforeBuild = ::beforeBuild,
props = {
validationDisplayMode = MANUAL
eventSubscription = ::subscribeToEvent
commandLifecycle = ::commandLifecycle
}
) {
Column(
Expand Down Expand Up @@ -105,15 +104,13 @@ public abstract class CommandDialog<C : CommandMessage, B : ValidatingBuilder<C>

/**
* A function, which, given a command message that is about to be posted,
* should subscribe to a respective event that is expected to arrive in
* response to handling that command.
* should provide the [CommandLifecycle] object that defines how the
* command's outcomes should be handled.
*
* @param command A command, which is going to be posted.
* @return A subscription to the event that is expected to arrive in
* response to handling [command].
* @return A respectively configured [CommandLifecycle] instance.
*/
protected abstract fun subscribeToEvent(command: C):
EventSubscription<out EventMessage>
protected abstract fun commandLifecycle(command: C): CommandLifecycle<C>

/**
* Allows to programmatically amend the command message builder before
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import com.google.protobuf.Message
import io.spine.base.CommandMessage
import io.spine.base.EventMessage
import io.spine.chords.client.EventSubscription
import io.spine.chords.client.CommandLifecycle
import io.spine.chords.client.form.CommandMessageForm
import io.spine.chords.core.layout.AbstractWizardPage
import io.spine.chords.core.layout.Wizard
Expand Down Expand Up @@ -74,7 +73,7 @@ public abstract class CommandWizard<C : CommandMessage, B : ValidatingBuilder<ou
onBeforeBuild = { beforeBuild(it) }
) {
validationDisplayMode = MANUAL
eventSubscription = { subscribeToEvent(it) }
commandLifecycle = ::commandLifecycle
}

/**
Expand All @@ -99,15 +98,13 @@ public abstract class CommandWizard<C : CommandMessage, B : ValidatingBuilder<ou

/**
* A function, which, given a command message that is about to be posted,
* should subscribe to a respective event that is expected to arrive in
* response to handling that command.
* should provide the [CommandLifecycle] object that defines how the
* command's outcomes should be handled.
*
* @param command
* a command, which is going to be posted.
* @return a subscription to the event that is expected to arrive in response
* to handling [command]
* @param command A command, which is going to be posted.
* @return A respectively configured [CommandLifecycle] instance.
*/
protected abstract fun subscribeToEvent(command: C): EventSubscription<out EventMessage>
protected abstract fun commandLifecycle(command: C): CommandLifecycle<C>

/**
* Allows to programmatically amend the command message builder before
Expand Down Expand Up @@ -142,7 +139,7 @@ public abstract class CommandWizard<C : CommandMessage, B : ValidatingBuilder<ou

/**
* A base class for a page in `CommandWizard`, which helps fill in a message
* that constitutes the content of one the command message's fields.
* that constitutes the content of one of the command message's fields.
*
* @param M A type of the command's field edited in this page.
* @param B A builder type of the command's field edited in this page.
Expand Down
20 changes: 10 additions & 10 deletions core/src/main/kotlin/io/spine/chords/core/appshell/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import androidx.compose.ui.window.application
import io.spine.chords.core.layout.Dialog
import io.spine.chords.core.layout.ConfirmationDialog
import io.spine.chords.core.layout.DialogSetup
import io.spine.chords.core.layout.DialogDisplayMode
import io.spine.chords.core.layout.WindowType
import io.spine.chords.core.writeOnce
import java.awt.Dimension

Expand Down Expand Up @@ -85,13 +85,13 @@ public var app: Application by writeOnce(false)
* ```
* override fun ComponentDefaultsScope.componentDefaults() {
* Dialog::class defaultsTo {
* displayMode = DesktopWindow
* windowType = DesktopWindow
* look = Look(
* buttonsPanelPadding = 20.pt
* }
* }
* ConfirmationDialog::class defaultsTo {
* displayMode = Lightweight
* windowType = LightweightWindow
* }
* }
* ```
Expand All @@ -100,21 +100,21 @@ public var app: Application by writeOnce(false)
* like this in your application:
* ```
* MyCustomDialog.open {
* dialogWidth = 600.dp
* dialogHeight = 400.dp
* width = 600.dp
* height = 400.dp
* }
* ```
*
* Then the dialog instance that will actually be created will implicitly have
* all of these property values:
* ```
* {
* displayMode = DesktopWindow
* windowType = DesktopWindow
* look = Look(
* buttonsPanelPadding = 20.pt
* }
* dialogWidth = 600.dp
* dialogHeight = 400.dp
* width = 600.dp
* height = 400.dp
* }
* ```
*
Expand All @@ -131,8 +131,8 @@ public var app: Application by writeOnce(false)
* override those found in base classes. In the example above, since
* [ConfirmationDialog] extends [Dialog], all instances of `ConfirmationDialog`
* declared within the application will get a value of
* [displayMode][ConfirmationDialog.displayMode] equal to
* [Lightweight][DialogDisplayMode.Lightweight].
* [displayMode][ConfirmationDialog.windowType] equal to
* [Lightweight][WindowType.LightweightWindow].
*
* @param name An application's name, which is in particular displayed in
* the application window's title.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ public class ConfirmationDialog : Dialog() {

yesButtonText = "Yes"
noButtonText = "No"
dialogWidth = 430.dp
dialogHeight = 210.dp
width = 430.dp
height = 210.dp
}

/**
Expand Down
Loading
Loading