diff --git a/project/src/main/scala/definitions/ReactRedux.scala b/project/src/main/scala/definitions/ReactRedux.scala index 13207d8..a98e0cb 100644 --- a/project/src/main/scala/definitions/ReactRedux.scala +++ b/project/src/main/scala/definitions/ReactRedux.scala @@ -20,7 +20,9 @@ object ReactRedux extends ScalaJsModule { ) override val internalDependencies: Seq[ClasspathDep[ProjectReference]] = Seq( - ReactCore.definition + ReactCore.definition, + ReactTest.definition % "test", + ReactTestDom.definition % "test" ) override val runtimeDependencies: Def.Initialize[Seq[ModuleID]] = Def.setting(Seq( diff --git a/redux/src/main/scala/scommons/react/redux/task/TaskManager.scala b/redux/src/main/scala/scommons/react/redux/task/TaskManager.scala new file mode 100644 index 0000000..9113806 --- /dev/null +++ b/redux/src/main/scala/scommons/react/redux/task/TaskManager.scala @@ -0,0 +1,144 @@ +package scommons.react.redux.task + +import scommons.react._ +import scommons.react.hooks._ + +import scala.scalajs.js +import scala.scalajs.js.{Error, JavaScriptException} +import scala.util.{Failure, Success, Try} + +case class TaskManagerProps(startTask: Option[AbstractTask]) + +/** + * Handles status of running tasks. + */ +object TaskManager extends FunctionComponent[TaskManagerProps] { + + var uiComponent: UiComponent[TaskManagerUiProps] = _ + + var errorHandler: PartialFunction[Try[_], (Option[String], Option[String])] = PartialFunction.empty + + private case class TaskManagerState(taskCount: Int = 0, + status: Option[String] = None, + error: Option[String] = None, + errorDetails: Option[String] = None) + + protected def render(compProps: Props): ReactElement = { + val props = compProps.wrapped + val (state, setState) = useStateUpdater(() => TaskManagerState()) + + if (uiComponent == null) { + throw JavaScriptException(Error("TaskManager.uiComponent is not specified")) + } + + useEffect({ () => + props.startTask.foreach { task => + onTaskStart(setState, task) + } + }, List(props.startTask match { + case None => js.undefined + case Some(task) => task.asInstanceOf[js.Any] + })) + + <(uiComponent())(^.wrapped := TaskManagerUiProps( + showLoading = state.taskCount > 0, + status = state.status, + onHideStatus = { () => + setState(_.copy(status = None)) + }, + error = state.error, + errorDetails = state.errorDetails, + onCloseErrorPopup = { () => + setState(_.copy(error = None, errorDetails = None)) + } + ))() + } + + private def onTaskStart(setState: js.Function1[js.Function1[TaskManagerState, TaskManagerState], Unit], + task: AbstractTask): Unit = { + + task.onComplete { value: Try[_] => + onTaskFinish(setState, task, value) + } + + setState(s => s.copy( + taskCount = s.taskCount + 1, + status = Some(s"${task.message}...") + )) + } + + private def onTaskFinish(setState: js.Function1[js.Function1[TaskManagerState, TaskManagerState], Unit], + task: AbstractTask, + value: Try[_]): Unit = { + + val durationMillis = System.currentTimeMillis() - task.startTime + val statusMessage = s"${task.message}...Done ${formatDuration(durationMillis)} sec." + + def defaultErrorHandler(value: Try[_]): (Option[String], Option[String]) = value match { + case Success(_) => (None, None) + case Failure(e) => (Some(e.toString), Some(printStackTrace(e))) + } + + val (error, errorDetails) = errorHandler.applyOrElse(value, defaultErrorHandler) + + setState(s => s.copy( + taskCount = s.taskCount - 1, + status = Some(statusMessage), + error = error, + errorDetails = errorDetails + )) + } + + private[task] def formatDuration(durationMillis: Long): String = { + "%.3f".format(durationMillis / 1000.0) + } + + private[task] def printStackTrace(x: Throwable): String = { + val sb = new StringBuilder(x.toString) + val trace = x.getStackTrace + for (t <- trace) { + sb.append("\n\tat ").append(t) + } + + val cause = x.getCause + if (cause != null) { + printStackTraceAsCause(sb, cause, trace) + } + + sb.toString + } + + /** + * Print stack trace as a cause for the specified stack trace. + */ + private def printStackTraceAsCause(sb: StringBuilder, + cause: Throwable, + causedTrace: Array[StackTraceElement]): Unit = { + + // Compute number of frames in common between this and caused + val trace = cause.getStackTrace + var m = trace.length - 1 + var n = causedTrace.length - 1 + while (m >= 0 && n >= 0 && trace(m) == causedTrace(n)) { + m -= 1 + n -= 1 + } + + val framesInCommon = trace.length - 1 - m + sb.append("\nCaused by: " + cause) + + for (i <- 0 to m) { + sb.append("\n\tat ").append(trace(i)) + } + + if (framesInCommon != 0) { + sb.append("\n\t... ").append(framesInCommon).append(" more") + } + + // Recurse if we have a cause + val ourCause = cause.getCause + if (ourCause != null) { + printStackTraceAsCause(sb, ourCause, trace) + } + } +} diff --git a/redux/src/main/scala/scommons/react/redux/task/TaskManagerUiProps.scala b/redux/src/main/scala/scommons/react/redux/task/TaskManagerUiProps.scala new file mode 100644 index 0000000..dfb8ff7 --- /dev/null +++ b/redux/src/main/scala/scommons/react/redux/task/TaskManagerUiProps.scala @@ -0,0 +1,8 @@ +package scommons.react.redux.task + +case class TaskManagerUiProps(showLoading: Boolean, + status: Option[String], + onHideStatus: () => Unit, + error: Option[String], + errorDetails: Option[String], + onCloseErrorPopup: () => Unit) diff --git a/redux/src/test/scala/scommons/react/redux/task/FutureTaskSpec.scala b/redux/src/test/scala/scommons/react/redux/task/FutureTaskSpec.scala index d45c491..7f3e283 100644 --- a/redux/src/test/scala/scommons/react/redux/task/FutureTaskSpec.scala +++ b/redux/src/test/scala/scommons/react/redux/task/FutureTaskSpec.scala @@ -1,17 +1,12 @@ package scommons.react.redux.task -import org.scalamock.scalatest.AsyncMockFactory -import org.scalatest.{AsyncFlatSpec, Matchers, Succeeded} +import org.scalatest.Succeeded +import scommons.react.test.dom.AsyncTestSpec -import scala.concurrent.{ExecutionContext, Future} -import scala.scalajs.concurrent.JSExecutionContext +import scala.concurrent.Future import scala.util.{Success, Try} -class FutureTaskSpec extends AsyncFlatSpec - with Matchers - with AsyncMockFactory { - - implicit override val executionContext: ExecutionContext = JSExecutionContext.queue +class FutureTaskSpec extends AsyncTestSpec { it should "call future.onComplete when onComplete" in { //given diff --git a/redux/src/test/scala/scommons/react/redux/task/TaskManagerSpec.scala b/redux/src/test/scala/scommons/react/redux/task/TaskManagerSpec.scala new file mode 100644 index 0000000..61e165e --- /dev/null +++ b/redux/src/test/scala/scommons/react/redux/task/TaskManagerSpec.scala @@ -0,0 +1,267 @@ +package scommons.react.redux.task + +import org.scalactic.source.Position +import org.scalatest.{Assertion, Succeeded} +import scommons.react._ +import scommons.react.test.dom.AsyncTestSpec +import scommons.react.test.raw.TestRenderer +import scommons.react.test.util.{ShallowRendererUtils, TestRendererUtils} + +import scala.concurrent.{Future, Promise} +import scala.scalajs.js.JavaScriptException + +class TaskManagerSpec extends AsyncTestSpec + with ShallowRendererUtils + with TestRendererUtils { + + TaskManager.uiComponent = new FunctionComponent[TaskManagerUiProps] { + override protected def render(props: Props): ReactElement = { + <.>.empty + } + } + + it should "fail if uiComponent is not set when render" in { + //given + val saved = TaskManager.uiComponent + TaskManager.uiComponent = null + val props = TaskManagerProps(None) + + //when + val JavaScriptException(error) = the[JavaScriptException] thrownBy { + shallowRender(<(TaskManager())(^.wrapped := props)()) + } + + //then + s"$error" shouldBe "Error: TaskManager.uiComponent is not specified" + + //restore default + TaskManager.uiComponent = saved + Succeeded + } + + it should "set status to None when onHideStatus" in { + //given + val task = FutureTask("Fetching data", Promise[Unit]().future) + val props = TaskManagerProps(Some(task)) + val renderer = createTestRenderer(<(TaskManager())(^.wrapped := props)()) + val uiProps = findComponentProps(renderer.root, TaskManager.uiComponent) + assertUiProps(uiProps, expected(showLoading = true, status = Some(s"${task.message}\\.\\.\\."))) + + //when + uiProps.onHideStatus() + + //then + val updatedUiProps = findComponentProps(renderer.root, TaskManager.uiComponent) + assertUiProps(updatedUiProps, expected(showLoading = true, status = None)) + } + + it should "set error to None when onCloseErrorPopup" in { + //given + val promise = Promise[Unit]() + val task = FutureTask("Fetching data", promise.future) + val props = TaskManagerProps(Some(task)) + val renderer = createTestRenderer(<(TaskManager())(^.wrapped := props)()) + val uiProps = findComponentProps(renderer.root, TaskManager.uiComponent) + assertUiProps(uiProps, expected(showLoading = true, status = Some(s"${task.message}\\.\\.\\."))) + + val e = new Exception("Test error") + promise.failure(e) + + eventually { + val uiPropsV2 = findComponentProps(renderer.root, TaskManager.uiComponent) + assertUiProps(uiPropsV2, expected( + showLoading = false, + status = Some(s"${task.message}\\.\\.\\.Done \\d+\\.\\d+ sec\\."), + error = Some(e.toString), + errorDetails = Some(TaskManager.printStackTrace(e)) + )) + + //when + uiPropsV2.onCloseErrorPopup() + + //then + val uiPropsV3 = findComponentProps(renderer.root, TaskManager.uiComponent) + assertUiProps(uiPropsV3, expected( + showLoading = false, + status = Some(s"${task.message}\\.\\.\\.Done \\d+\\.\\d+ sec\\."), + error = None, + errorDetails = None + )) + } + } + + it should "render loading and status" in { + //given + val task = FutureTask("Fetching data", Promise[Unit]().future) + val props = TaskManagerProps(Some(task)) + val component = <(TaskManager())(^.wrapped := props)() + + //when + val result = testRender(component) + + //then + assertUiProps(findComponentProps(result, TaskManager.uiComponent), expected( + showLoading = true, + status = Some(s"${task.message}\\.\\.\\.") + )) + } + + it should "render error when exception" in { + //given + val promise = Promise[Unit]() + val task = FutureTask("Fetching data", promise.future) + val props = TaskManagerProps(Some(task)) + val renderer = createTestRenderer(<(TaskManager())(^.wrapped := props)()) + val uiProps = findComponentProps(renderer.root, TaskManager.uiComponent) + assertUiProps(uiProps, expected(showLoading = true, status = Some(s"${task.message}..."))) + val e = new Exception("Test error") + + //when + promise.failure(e) + + //then + eventually { + assertUiProps(findComponentProps(renderer.root, TaskManager.uiComponent), expected( + showLoading = false, + status = Some(s"${task.message}\\.\\.\\.Done \\d+\\.\\d+ sec\\."), + error = Some(e.toString), + errorDetails = Some(TaskManager.printStackTrace(e)) + )) + } + } + + it should "render status when task completed successfully" in { + //given + val promise = Promise[String]() + val task = FutureTask("Fetching data", promise.future) + val props = TaskManagerProps(Some(task)) + val renderer = createTestRenderer(<(TaskManager())(^.wrapped := props)()) + val uiProps = findComponentProps(renderer.root, TaskManager.uiComponent) + assertUiProps(uiProps, expected(showLoading = true, status = Some(s"${task.message}..."))) + val resp = "Ok" + + //when + promise.success(resp) + + //then + eventually { + assertUiProps(findComponentProps(renderer.root, TaskManager.uiComponent), expected( + showLoading = false, + status = Some(s"${task.message}\\.\\.\\.Done \\d+\\.\\d+ sec\\."), + error = None, + errorDetails = None + )) + } + } + + it should "render status of already completed task" in { + //given + val task = FutureTask("Fetching data", Future.successful(())) + val props = TaskManagerProps(Some(task)) + val renderer = createTestRenderer(<(TaskManager())(^.wrapped := props)()) + assertUiProps(findComponentProps(renderer.root, TaskManager.uiComponent), expected( + showLoading = true, + status = Some(s"${task.message}...") + )) + + //when & then + eventually { + assertUiProps(findComponentProps(renderer.root, TaskManager.uiComponent), expected( + showLoading = false, + status = Some(s"${task.message}\\.\\.\\.Done \\d+\\.\\d+ sec\\."), + error = None, + errorDetails = None + )) + } + } + + it should "render status of concurrent tasks" in { + //given + val promise1 = Promise[String]() + val task1 = FutureTask("Fetching data 1", promise1.future) + val renderer = createTestRenderer(<(TaskManager())(^.wrapped := TaskManagerProps(Some(task1)))()) + assertUiProps(findComponentProps(renderer.root, TaskManager.uiComponent), expected( + showLoading = true, + status = Some(s"${task1.message}...") + )) + + val promise2 = Promise[String]() + val task2 = FutureTask("Fetching data 2", promise2.future) + + TestRenderer.act { () => + renderer.update(<(TaskManager())(^.wrapped := TaskManagerProps(Some(task2)))()) + } + assertUiProps(findComponentProps(renderer.root, TaskManager.uiComponent), expected( + showLoading = true, + status = Some(s"${task2.message}...") + )) + + //when + promise1.success("Ok") + + //then + eventually { + assertUiProps(findComponentProps(renderer.root, TaskManager.uiComponent), expected( + showLoading = true, + status = Some(s"${task1.message}\\.\\.\\.Done \\d+\\.\\d+ sec\\.") + )) + }.flatMap { _ => + //when + promise2.success("Ok") + + //then + eventually { + assertUiProps(findComponentProps(renderer.root, TaskManager.uiComponent), expected( + showLoading = false, + status = Some(s"${task2.message}\\.\\.\\.Done \\d+\\.\\d+ sec\\.") + )) + } + } + } + + it should "format duration in seconds properly" in { + //when & then + TaskManager.formatDuration(0L) shouldBe "0.000" + TaskManager.formatDuration(3L) shouldBe "0.003" + TaskManager.formatDuration(22L) shouldBe "0.022" + TaskManager.formatDuration(33L) shouldBe "0.033" + TaskManager.formatDuration(330L) shouldBe "0.330" + TaskManager.formatDuration(333L) shouldBe "0.333" + TaskManager.formatDuration(1132L) shouldBe "1.132" + TaskManager.formatDuration(1333L) shouldBe "1.333" + + Succeeded + } + + private def assertUiProps(uiProps: TaskManagerUiProps, + expectedProps: TaskManagerUiProps + )(implicit pos: Position): Assertion = { + + inside(uiProps) { case TaskManagerUiProps(showLoading, status, _, error, errorDetails, _) => + showLoading shouldBe expectedProps.showLoading + expectedProps.status match { + case None => status shouldBe None + case Some(statusRegex) => status.get should fullyMatch regex statusRegex + } + error shouldBe expectedProps.error + errorDetails shouldBe expectedProps.errorDetails + } + } + + def expected(showLoading: Boolean, + status: Option[String] = None, + onHideStatus: () => Unit = () => (), + error: Option[String] = None, + errorDetails: Option[String] = None, + onCloseErrorPopup: () => Unit = () => ()): TaskManagerUiProps = { + + TaskManagerUiProps( + showLoading, + status, + onHideStatus, + error, + errorDetails, + onCloseErrorPopup + ) + } +}