From 03c3e894ebcb94018a41fec001d539f5c6c10272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Piaggio?= Date: Wed, 25 Sep 2024 17:28:13 -0300 Subject: [PATCH 1/3] disable submit/retract on deadline due --- .../main/scala/explore/ExploreLayout.scala | 4 +- .../scala/explore/proposal/CallDeadline.scala | 39 ++- .../proposal/ProposalTabContents.scala | 235 ++++++++++-------- .../src/main/scala/explore/Proposal.scala | 13 +- 4 files changed, 150 insertions(+), 141 deletions(-) diff --git a/explore/src/main/scala/explore/ExploreLayout.scala b/explore/src/main/scala/explore/ExploreLayout.scala index 87a234fae5..b4ddbe9e80 100644 --- a/explore/src/main/scala/explore/ExploreLayout.scala +++ b/explore/src/main/scala/explore/ExploreLayout.scala @@ -215,7 +215,7 @@ object ExploreLayout: val deadlineStr: String = deadline - .map(Proposal.deadlineString) + .map(d => s" until the proposal deadline at ${Proposal.deadlineString(d)}") .orEmpty val cacheKey: String = @@ -300,7 +300,7 @@ object ExploreLayout: props.resolution.renderP(props.model), if (isSubmitted) Message(text = - s"The proposal has been submitted as ${proposalReference.foldMap(_.label)} and may be retracted until the proposal deadline at ${deadlineStr}." + s"The proposal has been submitted as ${proposalReference.foldMap(_.label)} and may be retracted${deadlineStr}." ) else EmptyVdom ) diff --git a/explore/src/main/scala/explore/proposal/CallDeadline.scala b/explore/src/main/scala/explore/proposal/CallDeadline.scala index 239f06332a..9af7b5a9e9 100644 --- a/explore/src/main/scala/explore/proposal/CallDeadline.scala +++ b/explore/src/main/scala/explore/proposal/CallDeadline.scala @@ -3,41 +3,32 @@ package explore.proposal -import cats.effect.IO -import crystal.react.hooks.* import explore.components.ui.ExploreStyles import explore.model.Proposal -import fs2.Stream import japgolly.scalajs.react.* import japgolly.scalajs.react.vdom.html_<^.* import lucuma.core.util.Timestamp import lucuma.react.common.ReactFnProps import lucuma.react.primereact.Message -import java.time.Instant -import scala.concurrent.duration.* - -case class CallDeadline(deadline: Timestamp) extends ReactFnProps(CallDeadline.component) +case class CallDeadline(now: Timestamp, deadline: Timestamp) + extends ReactFnProps(CallDeadline.component) object CallDeadline: private type Props = CallDeadline private val component = - ScalaFnComponent - .withHooks[Props] - .useStreamOnMount: - Stream.eval(IO(Instant.now())) ++ - Stream - .awakeDelay[IO](1.seconds) - .flatMap(_ => Stream.eval(IO(Instant.now()))) - .render: (p, n) => - n.toOption.map: n => - val (deadlineStr, left) = Proposal.deadlineAndTimeLeft(n, p.deadline) - val text = left.fold(deadlineStr)(l => s"$deadlineStr [$l]") + ScalaFnComponent[Props]: props => + val (deadlineStr, left): (String, Option[String]) = + Proposal.deadlineAndTimeLeft(props.now, props.deadline) + val text: String = + left.fold(deadlineStr)(l => s"$deadlineStr [$l]") + val severity: Message.Severity = + left.fold(Message.Severity.Error)(_ => Message.Severity.Info) - <.span(ExploreStyles.ProposalDeadline)( - Message( - text = s"Deadline: $text", - severity = Message.Severity.Info - ) - ) + <.span(ExploreStyles.ProposalDeadline)( + Message( + text = s"Deadline: $text", + severity = severity + ) + ) diff --git a/explore/src/main/scala/explore/proposal/ProposalTabContents.scala b/explore/src/main/scala/explore/proposal/ProposalTabContents.scala index ef2cf4bf8b..6d05920b1b 100644 --- a/explore/src/main/scala/explore/proposal/ProposalTabContents.scala +++ b/explore/src/main/scala/explore/proposal/ProposalTabContents.scala @@ -8,7 +8,7 @@ import cats.syntax.all.* import clue.FetchClient import clue.ResponseException import clue.data.syntax.* -import crystal.Pot +import crystal.* import crystal.react.* import crystal.react.hooks.* import explore.* @@ -48,9 +48,13 @@ import lucuma.ui.components.LoginStyles import lucuma.ui.primereact.* import lucuma.ui.reusability.given import lucuma.ui.sso.UserVault -import lucuma.ui.syntax.all.given +import lucuma.ui.syntax.all.* import org.typelevel.log4cats.Logger import queries.common.ProposalQueriesGQL.* +import fs2.Stream +import scala.concurrent.duration.* +import explore.model.UserInvitation +import explore.model.ProgramUserWithRole case class ProposalTabContents( programId: Program.Id, @@ -95,122 +99,137 @@ object ProposalTabContents: .useLayoutEffectWithDepsBy((props, _, _, _, _) => props.programDetails.get.proposal.flatMap(_.callId) )((_, _, _, _, e) => _ => e.setState(none)) - .render: (props, ctx, isUpdatingStatus, readonly, errorMessage) => - + .useStreamOnMount: + Stream + .fixedRateStartImmediately[IO](1.second) + .evalMap: _ => + IO.monotonic.map(finiteDuration => Timestamp.ofEpochMilli(finiteDuration.toMillis).toPot) + .render: (props, ctx, isUpdatingStatus, readonly, errorMessage, nowPot) => import ctx.given - val invitations = props.programDetails.zoom(ProgramDetails.invitations) - val users = props.programDetails.zoom(ProgramDetails.allUsers) + nowPot.toPot.flatten.renderPot: now => + val invitations: View[List[UserInvitation]] = + props.programDetails.zoom(ProgramDetails.invitations) + val users: View[List[ProgramUserWithRole]] = + props.programDetails.zoom(ProgramDetails.allUsers) - val isStdUser = props.userVault.map(_.user).collect { case _: StandardUser => () }.isDefined - val proposalStatus = props.programDetails.get.proposalStatus + val isStdUser: Boolean = + props.userVault.map(_.user).collect { case StandardUser(_, _, _, _) => () }.isDefined + val proposalStatus: ProposalStatus = + props.programDetails.get.proposalStatus - def updateStatus(newStatus: ProposalStatus): Callback = - (for { - _ <- SetProposalStatus[IO] - .execute: - SetProposalStatusInput(programId = props.programId.assign, status = newStatus) - .onError: - case ResponseException(errors, _) => - errorMessage.setState(errors.head.message.some).to[IO] - case e => - errorMessage.setState(Some(e.getMessage.toString)).to[IO] - .void - _ <- props.programDetails.zoom(ProgramDetails.proposalStatus).set(newStatus).toAsync - } yield ()).switching(isUpdatingStatus.async, IsUpdatingStatus(_)).runAsync + def updateStatus(newStatus: ProposalStatus): Callback = + (for { + _ <- SetProposalStatus[IO] + .execute: + SetProposalStatusInput(programId = props.programId.assign, status = newStatus) + .onError: + case ResponseException(errors, _) => + errorMessage.setState(errors.head.message.some).to[IO] + case e => + errorMessage.setState(Some(e.getMessage.toString)).to[IO] + .void + _ <- props.programDetails.zoom(ProgramDetails.proposalStatus).set(newStatus).toAsync + } yield ()).switching(isUpdatingStatus.async, IsUpdatingStatus(_)).runAsync - if (props.programDetails.get.programType =!= ProgramType.Science) - <.div(ExploreStyles.HVCenter)( - Message( - text = "Only Science Program Types can have proposals.", - severity = Message.Severity.Info + if (props.programDetails.get.programType =!= ProgramType.Science) + <.div(ExploreStyles.HVCenter)( + Message( + text = "Only Science Program Types can have proposals.", + severity = Message.Severity.Info + ) ) - ) - else - props.programDetails - .zoom(ProgramDetails.proposal) - .mapValue((proposalView: View[Proposal]) => - val piPartner = - props.programDetails.zoom(ProgramDetails.piPartner.some).get + else + props.programDetails + .zoom(ProgramDetails.proposal) + .mapValue((proposalView: View[Proposal]) => + val piPartner = + props.programDetails.zoom(ProgramDetails.piPartner.some).get + + val deadline: Option[Timestamp] = + // proposalView.get.deadline(props.cfps, piPartner) + Timestamp.Epoch.some - val deadline: Option[Timestamp] = - proposalView.get.deadline(props.cfps, piPartner) + val isDueDeadline: Boolean = + deadline.exists(_ < now) - <.div( - ExploreStyles.ProposalTab, - ProposalEditor( - props.programId, - props.userVault.map(_.user.id), - proposalView, - props.undoStacks, - props.timeEstimateRange, - users, - invitations, - props.attachments, - props.userVault.map(_.token), - props.cfps, - props.layout, - readonly - ), - Toolbar(left = - <.div( - ExploreStyles.ProposalSubmissionBar, - Tag( - value = props.programDetails.get.proposalStatus.name, - severity = - if (proposalStatus === ProposalStatus.Accepted) Tag.Severity.Success - else Tag.Severity.Danger + <.div( + ExploreStyles.ProposalTab, + ProposalEditor( + props.programId, + props.userVault.map(_.user.id), + proposalView, + props.undoStacks, + props.timeEstimateRange, + users, + invitations, + props.attachments, + props.userVault.map(_.token), + props.cfps, + props.layout, + readonly + ), + Toolbar(left = + <.div( + ExploreStyles.ProposalSubmissionBar, + Tag( + value = props.programDetails.get.proposalStatus.name, + severity = + if (proposalStatus === ProposalStatus.Accepted) Tag.Severity.Success + else Tag.Severity.Danger + ) + .when(proposalStatus > ProposalStatus.Submitted), + // TODO: Validate proposal before allowing submission + React + .Fragment( + Button( + label = "Submit Proposal", + onClick = updateStatus(ProposalStatus.Submitted), + disabled = + isUpdatingStatus.get.value || proposalView.get.callId.isEmpty || isDueDeadline + ).compact.tiny, + deadline.map(CallDeadline(now, _)) + ) + .when: + isStdUser && proposalStatus === ProposalStatus.NotSubmitted + , + Button( + "Retract Proposal", + severity = Button.Severity.Warning, + onClick = updateStatus(ProposalStatus.NotSubmitted), + disabled = isUpdatingStatus.get.value || isDueDeadline + ).compact.tiny + .when: + isStdUser && proposalStatus === ProposalStatus.Submitted && !isDueDeadline + , + errorMessage.value + .map(r => Message(text = r, severity = Message.Severity.Error)) ) - .when(proposalStatus > ProposalStatus.Submitted), - // TODO: Validate proposal before allowing submission - React - .Fragment( - Button( - label = "Submit Proposal", - onClick = updateStatus(ProposalStatus.Submitted), - disabled = isUpdatingStatus.get.value || proposalView.get.callId.isEmpty - ).compact.tiny, - deadline.map(CallDeadline.apply) + ) + ) + ) + .getOrElse( + if (isStdUser) + <.div(ExploreStyles.HVCenter)( + Button( + label = "Create a Proposal", + icon = Icons.FileCirclePlus.withClass(LoginStyles.LoginOrcidIcon), + clazz = LoginStyles.LoginBoxButton, + severity = Button.Severity.Secondary, + onClick = createProposal( + props.programId, + props.programDetails ) - .when: - isStdUser && proposalStatus === ProposalStatus.NotSubmitted - , + ).big + ) + else + <.div(ExploreStyles.HVCenter)( Button( - "Retract Proposal", - severity = Button.Severity.Warning, - onClick = updateStatus(ProposalStatus.NotSubmitted), - disabled = isUpdatingStatus.get.value - ).compact.tiny - .when: - isStdUser && proposalStatus === ProposalStatus.Submitted - , - errorMessage.value.map(r => Message(text = r, severity = Message.Severity.Error)) + label = "Login with ORCID to create a Proposal", + icon = Image(src = Resources.OrcidLogo, clazz = LoginStyles.LoginOrcidIcon), + clazz = LoginStyles.LoginBoxButton, + severity = Button.Severity.Secondary, + onClick = ctx.sso.switchToORCID.runAsync + ).big ) - ) ) - ) - .getOrElse( - if (isStdUser) - <.div(ExploreStyles.HVCenter)( - Button( - label = "Create a Proposal", - icon = Icons.FileCirclePlus.withClass(LoginStyles.LoginOrcidIcon), - clazz = LoginStyles.LoginBoxButton, - severity = Button.Severity.Secondary, - onClick = createProposal( - props.programId, - props.programDetails - ) - ).big - ) - else - <.div(ExploreStyles.HVCenter)( - Button( - label = "Login with ORCID to create a Proposal", - icon = Image(src = Resources.OrcidLogo, clazz = LoginStyles.LoginOrcidIcon), - clazz = LoginStyles.LoginBoxButton, - severity = Button.Severity.Secondary, - onClick = ctx.sso.switchToORCID.runAsync - ).big - ) - ) diff --git a/model/shared/src/main/scala/explore/Proposal.scala b/model/shared/src/main/scala/explore/Proposal.scala index 35ecdab442..0a7b7eed43 100644 --- a/model/shared/src/main/scala/explore/Proposal.scala +++ b/model/shared/src/main/scala/explore/Proposal.scala @@ -19,9 +19,8 @@ import monocle.Focus import monocle.Iso import monocle.Lens -import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneOffset +import java.time.Duration case class Proposal( callId: Option[CallForProposals.Id], @@ -67,11 +66,11 @@ object Proposal: val Default = Proposal(None, None, None, None, None, None) - def deadlineAndTimeLeft(n: Instant, deadline: Timestamp): (String, Option[String]) = { - val deadlineLDT = deadline.toLocalDateTime - val now = LocalDateTime.ofInstant(n, ZoneOffset.UTC) - val diff = java.time.Duration.between(now, deadlineLDT) - val deadlineStr: String = deadlineString(deadline) + def deadlineAndTimeLeft(now: Timestamp, deadline: Timestamp): (String, Option[String]) = { + val deadlineLDT: LocalDateTime = deadline.toLocalDateTime + val nowLDT: LocalDateTime = now.toLocalDateTime + val diff: Duration = Duration.between(nowLDT, deadlineLDT) + val deadlineStr: String = deadlineString(deadline) if (diff.isNegative) (deadlineStr, None) else val left = Constants.DurationLongWithSecondsFormatter(diff) From 9e8f2c03de0cd55eb710bed0b65a6564db15d4c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Piaggio?= Date: Wed, 25 Sep 2024 18:40:33 -0300 Subject: [PATCH 2/3] encapsulate rerender logic --- .../scala/explore/proposal/CallDeadline.scala | 34 --- .../proposal/ProposalSubmissionBar.scala | 153 +++++++++++++ .../proposal/ProposalTabContents.scala | 216 ++++++------------ .../src/main/scala/explore/Proposal.scala | 2 +- 4 files changed, 226 insertions(+), 179 deletions(-) delete mode 100644 explore/src/main/scala/explore/proposal/CallDeadline.scala create mode 100644 explore/src/main/scala/explore/proposal/ProposalSubmissionBar.scala diff --git a/explore/src/main/scala/explore/proposal/CallDeadline.scala b/explore/src/main/scala/explore/proposal/CallDeadline.scala deleted file mode 100644 index 9af7b5a9e9..0000000000 --- a/explore/src/main/scala/explore/proposal/CallDeadline.scala +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) -// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause - -package explore.proposal - -import explore.components.ui.ExploreStyles -import explore.model.Proposal -import japgolly.scalajs.react.* -import japgolly.scalajs.react.vdom.html_<^.* -import lucuma.core.util.Timestamp -import lucuma.react.common.ReactFnProps -import lucuma.react.primereact.Message - -case class CallDeadline(now: Timestamp, deadline: Timestamp) - extends ReactFnProps(CallDeadline.component) - -object CallDeadline: - private type Props = CallDeadline - - private val component = - ScalaFnComponent[Props]: props => - val (deadlineStr, left): (String, Option[String]) = - Proposal.deadlineAndTimeLeft(props.now, props.deadline) - val text: String = - left.fold(deadlineStr)(l => s"$deadlineStr [$l]") - val severity: Message.Severity = - left.fold(Message.Severity.Error)(_ => Message.Severity.Info) - - <.span(ExploreStyles.ProposalDeadline)( - Message( - text = s"Deadline: $text", - severity = severity - ) - ) diff --git a/explore/src/main/scala/explore/proposal/ProposalSubmissionBar.scala b/explore/src/main/scala/explore/proposal/ProposalSubmissionBar.scala new file mode 100644 index 0000000000..149f0b2958 --- /dev/null +++ b/explore/src/main/scala/explore/proposal/ProposalSubmissionBar.scala @@ -0,0 +1,153 @@ +// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package explore.proposal + +import cats.effect.IO +import cats.syntax.all.* +import clue.FetchClient +import clue.ResponseException +import clue.data.syntax.* +import crystal.* +import crystal.react.* +import crystal.react.hooks.* +import explore.DefaultErrorPolicy +import explore.components.ui.ExploreStyles +import explore.model.AppContext +import explore.model.Proposal +import explore.syntax.ui.* +import fs2.Stream +import japgolly.scalajs.react.* +import japgolly.scalajs.react.vdom.html_<^.* +import lucuma.core.model.CallForProposals +import lucuma.core.model.Program +import lucuma.core.util.NewType +import lucuma.core.util.Timestamp +import lucuma.react.common.ReactFnProps +import lucuma.react.primereact.Button +import lucuma.react.primereact.Message +import lucuma.react.primereact.Tag +import lucuma.react.primereact.Toolbar +import lucuma.schemas.ObservationDB +import lucuma.schemas.ObservationDB.Types.SetProposalStatusInput +import lucuma.schemas.enums.ProposalStatus +import lucuma.ui.primereact.* +import lucuma.ui.reusability.given +import org.typelevel.log4cats.Logger +import queries.common.ProposalQueriesGQL.SetProposalStatus + +import scala.concurrent.duration.* + +case class ProposalSubmissionBar( + programId: Program.Id, + proposalStatus: View[ProposalStatus], + deadline: Option[Timestamp], + callId: Option[CallForProposals.Id], + isStdUser: Boolean +) extends ReactFnProps(ProposalSubmissionBar.component) + +object ProposalSubmissionBar: + private type Props = ProposalSubmissionBar + + private object IsUpdatingStatus extends NewType[Boolean] + private type IsUpdatingStatus = IsUpdatingStatus.Type + + private def doUpdateStatus( + programId: Program.Id, + isUpdatingStatus: View[IsUpdatingStatus], + setLocalProposalStatus: ProposalStatus => IO[Unit], + setErrorMessage: Option[String] => IO[Unit] + )( + newStatus: ProposalStatus + )(using + FetchClient[IO, ObservationDB], + Logger[IO] + ): Callback = + (for { + _ <- SetProposalStatus[IO] + .execute: + SetProposalStatusInput(programId = programId.assign, status = newStatus) + .onError: + case ResponseException(errors, _) => + setErrorMessage(errors.head.message.some) + case e => + setErrorMessage(Some(e.getMessage.toString)) + .void + _ <- setLocalProposalStatus(newStatus) + } yield ()).switching(isUpdatingStatus.async, IsUpdatingStatus(_)).runAsync + + private val component = + ScalaFnComponent + .withHooks[Props] + .useContext(AppContext.ctx) + .useStateView(IsUpdatingStatus(false)) + .useStateView(none[String]) // Submission error message + .useLayoutEffectWithDepsBy((props, _, _, _) => props.callId): (_, _, _, e) => + _ => e.set(none) // Reset error message on CfP change + .useStreamOnMount: + Stream + .fixedRateStartImmediately[IO](1.second) + .evalMap: _ => + IO.monotonic.map(finiteDuration => Timestamp.ofEpochMilli(finiteDuration.toMillis)) + .render: (props, ctx, isUpdatingStatus, errorMessage, nowPot) => + import ctx.given + + val updateStatus: ProposalStatus => Callback = + doUpdateStatus( + props.programId, + isUpdatingStatus, + props.proposalStatus.async.set, + errorMessage.async.set + ) + + nowPot.toOption.flatten.map: now => + val isDueDeadline: Boolean = props.deadline.exists(_ < now) + + Toolbar(left = + <.div(ExploreStyles.ProposalSubmissionBar)( + Tag( + value = props.proposalStatus.get.name, + severity = + if (props.proposalStatus.get === ProposalStatus.Accepted) Tag.Severity.Success + else Tag.Severity.Danger + ) + .when(props.proposalStatus.get > ProposalStatus.Submitted), + // TODO: Validate proposal before allowing submission + React + .Fragment( + Button( + label = "Submit Proposal", + onClick = updateStatus(ProposalStatus.Submitted), + disabled = isUpdatingStatus.get.value || props.callId.isEmpty || isDueDeadline + ).compact.tiny, + props.deadline.map: deadline => + val (deadlineStr, left): (String, Option[String]) = + Proposal.deadlineAndTimeLeft(now, deadline) + val text: String = + left.fold(deadlineStr)(l => s"$deadlineStr [$l]") + val severity: Message.Severity = + left.fold(Message.Severity.Error)(_ => Message.Severity.Info) + + <.span(ExploreStyles.ProposalDeadline)( + Message( + text = s"Deadline: $text", + severity = severity + ) + ) + ) + .when: + props.isStdUser && props.proposalStatus.get === ProposalStatus.NotSubmitted + , + Button( + "Retract Proposal", + severity = Button.Severity.Warning, + onClick = updateStatus(ProposalStatus.NotSubmitted), + disabled = isUpdatingStatus.get.value || isDueDeadline + ).compact.tiny + .when: + props.isStdUser && props.proposalStatus.get === ProposalStatus.Submitted && !isDueDeadline + , + errorMessage.get + .map(r => Message(text = r, severity = Message.Severity.Error)) + ) + ) diff --git a/explore/src/main/scala/explore/proposal/ProposalTabContents.scala b/explore/src/main/scala/explore/proposal/ProposalTabContents.scala index 6d05920b1b..00ebd31b1b 100644 --- a/explore/src/main/scala/explore/proposal/ProposalTabContents.scala +++ b/explore/src/main/scala/explore/proposal/ProposalTabContents.scala @@ -6,11 +6,8 @@ package explore.proposal import cats.effect.IO import cats.syntax.all.* import clue.FetchClient -import clue.ResponseException -import clue.data.syntax.* import crystal.* import crystal.react.* -import crystal.react.hooks.* import explore.* import explore.DefaultErrorPolicy import explore.Icons @@ -20,8 +17,10 @@ import explore.model.AppContext import explore.model.CallForProposal import explore.model.ProgramDetails import explore.model.ProgramTimeRange +import explore.model.ProgramUserWithRole import explore.model.Proposal import explore.model.ProposalAttachment +import explore.model.UserInvitation import explore.model.layout.LayoutsMap import explore.syntax.ui.* import explore.undo.* @@ -32,14 +31,11 @@ import lucuma.core.enums.ProgramType import lucuma.core.model.Program import lucuma.core.model.StandardUser import lucuma.core.model.User -import lucuma.core.util.NewType import lucuma.core.util.Timestamp import lucuma.react.common.ReactFnProps import lucuma.react.primereact.Button import lucuma.react.primereact.Image import lucuma.react.primereact.Message -import lucuma.react.primereact.Tag -import lucuma.react.primereact.Toolbar import lucuma.schemas.ObservationDB import lucuma.schemas.ObservationDB.Types.* import lucuma.schemas.enums.ProposalStatus @@ -48,13 +44,8 @@ import lucuma.ui.components.LoginStyles import lucuma.ui.primereact.* import lucuma.ui.reusability.given import lucuma.ui.sso.UserVault -import lucuma.ui.syntax.all.* import org.typelevel.log4cats.Logger import queries.common.ProposalQueriesGQL.* -import fs2.Stream -import scala.concurrent.duration.* -import explore.model.UserInvitation -import explore.model.ProgramUserWithRole case class ProposalTabContents( programId: Program.Id, @@ -70,8 +61,6 @@ case class ProposalTabContents( object ProposalTabContents: private type Props = ProposalTabContents - private object IsUpdatingStatus extends NewType[Boolean] - private def createProposal( programId: Program.Id, programDetails: View[ProgramDetails] @@ -92,144 +81,83 @@ object ProposalTabContents: private val component = ScalaFnComponent .withHooks[Props] .useContext(AppContext.ctx) - .useStateView(IsUpdatingStatus(false)) - .useMemoBy((props, _, _) => props.programDetails.get.proposalStatus): (_, _, _) => + .useMemoBy((props, _) => props.programDetails.get.proposalStatus): (_, _) => p => p === ProposalStatus.Submitted || p === ProposalStatus.Accepted - .useState(none[String]) // Submission error message - .useLayoutEffectWithDepsBy((props, _, _, _, _) => - props.programDetails.get.proposal.flatMap(_.callId) - )((_, _, _, _, e) => _ => e.setState(none)) - .useStreamOnMount: - Stream - .fixedRateStartImmediately[IO](1.second) - .evalMap: _ => - IO.monotonic.map(finiteDuration => Timestamp.ofEpochMilli(finiteDuration.toMillis).toPot) - .render: (props, ctx, isUpdatingStatus, readonly, errorMessage, nowPot) => + .render: (props, ctx, readonly) => import ctx.given - nowPot.toPot.flatten.renderPot: now => - val invitations: View[List[UserInvitation]] = - props.programDetails.zoom(ProgramDetails.invitations) - val users: View[List[ProgramUserWithRole]] = - props.programDetails.zoom(ProgramDetails.allUsers) - - val isStdUser: Boolean = - props.userVault.map(_.user).collect { case StandardUser(_, _, _, _) => () }.isDefined - val proposalStatus: ProposalStatus = - props.programDetails.get.proposalStatus + val invitations: View[List[UserInvitation]] = + props.programDetails.zoom(ProgramDetails.invitations) + val users: View[List[ProgramUserWithRole]] = + props.programDetails.zoom(ProgramDetails.allUsers) - def updateStatus(newStatus: ProposalStatus): Callback = - (for { - _ <- SetProposalStatus[IO] - .execute: - SetProposalStatusInput(programId = props.programId.assign, status = newStatus) - .onError: - case ResponseException(errors, _) => - errorMessage.setState(errors.head.message.some).to[IO] - case e => - errorMessage.setState(Some(e.getMessage.toString)).to[IO] - .void - _ <- props.programDetails.zoom(ProgramDetails.proposalStatus).set(newStatus).toAsync - } yield ()).switching(isUpdatingStatus.async, IsUpdatingStatus(_)).runAsync + val isStdUser: Boolean = + props.userVault.map(_.user).collect { case StandardUser(_, _, _, _) => () }.isDefined - if (props.programDetails.get.programType =!= ProgramType.Science) - <.div(ExploreStyles.HVCenter)( - Message( - text = "Only Science Program Types can have proposals.", - severity = Message.Severity.Info - ) + if (props.programDetails.get.programType =!= ProgramType.Science) + <.div(ExploreStyles.HVCenter)( + Message( + text = "Only Science Program Types can have proposals.", + severity = Message.Severity.Info ) - else - props.programDetails - .zoom(ProgramDetails.proposal) - .mapValue((proposalView: View[Proposal]) => - val piPartner = - props.programDetails.zoom(ProgramDetails.piPartner.some).get - - val deadline: Option[Timestamp] = - // proposalView.get.deadline(props.cfps, piPartner) - Timestamp.Epoch.some + ) + else + props.programDetails + .zoom(ProgramDetails.proposal) + .mapValue((proposalView: View[Proposal]) => + val piPartner = + props.programDetails.zoom(ProgramDetails.piPartner.some).get - val isDueDeadline: Boolean = - deadline.exists(_ < now) + val deadline: Option[Timestamp] = + proposalView.get.deadline(props.cfps, piPartner) - <.div( - ExploreStyles.ProposalTab, - ProposalEditor( - props.programId, - props.userVault.map(_.user.id), - proposalView, - props.undoStacks, - props.timeEstimateRange, - users, - invitations, - props.attachments, - props.userVault.map(_.token), - props.cfps, - props.layout, - readonly - ), - Toolbar(left = - <.div( - ExploreStyles.ProposalSubmissionBar, - Tag( - value = props.programDetails.get.proposalStatus.name, - severity = - if (proposalStatus === ProposalStatus.Accepted) Tag.Severity.Success - else Tag.Severity.Danger - ) - .when(proposalStatus > ProposalStatus.Submitted), - // TODO: Validate proposal before allowing submission - React - .Fragment( - Button( - label = "Submit Proposal", - onClick = updateStatus(ProposalStatus.Submitted), - disabled = - isUpdatingStatus.get.value || proposalView.get.callId.isEmpty || isDueDeadline - ).compact.tiny, - deadline.map(CallDeadline(now, _)) - ) - .when: - isStdUser && proposalStatus === ProposalStatus.NotSubmitted - , - Button( - "Retract Proposal", - severity = Button.Severity.Warning, - onClick = updateStatus(ProposalStatus.NotSubmitted), - disabled = isUpdatingStatus.get.value || isDueDeadline - ).compact.tiny - .when: - isStdUser && proposalStatus === ProposalStatus.Submitted && !isDueDeadline - , - errorMessage.value - .map(r => Message(text = r, severity = Message.Severity.Error)) - ) - ) + <.div( + ExploreStyles.ProposalTab, + ProposalEditor( + props.programId, + props.userVault.map(_.user.id), + proposalView, + props.undoStacks, + props.timeEstimateRange, + users, + invitations, + props.attachments, + props.userVault.map(_.token), + props.cfps, + props.layout, + readonly + ), + ProposalSubmissionBar( + props.programId, + props.programDetails.zoom(ProgramDetails.proposalStatus), + deadline, + proposalView.get.callId, + isStdUser ) ) - .getOrElse( - if (isStdUser) - <.div(ExploreStyles.HVCenter)( - Button( - label = "Create a Proposal", - icon = Icons.FileCirclePlus.withClass(LoginStyles.LoginOrcidIcon), - clazz = LoginStyles.LoginBoxButton, - severity = Button.Severity.Secondary, - onClick = createProposal( - props.programId, - props.programDetails - ) - ).big - ) - else - <.div(ExploreStyles.HVCenter)( - Button( - label = "Login with ORCID to create a Proposal", - icon = Image(src = Resources.OrcidLogo, clazz = LoginStyles.LoginOrcidIcon), - clazz = LoginStyles.LoginBoxButton, - severity = Button.Severity.Secondary, - onClick = ctx.sso.switchToORCID.runAsync - ).big - ) - ) + ) + .getOrElse( + if (isStdUser) + <.div(ExploreStyles.HVCenter)( + Button( + label = "Create a Proposal", + icon = Icons.FileCirclePlus.withClass(LoginStyles.LoginOrcidIcon), + clazz = LoginStyles.LoginBoxButton, + severity = Button.Severity.Secondary, + onClick = createProposal( + props.programId, + props.programDetails + ) + ).big + ) + else + <.div(ExploreStyles.HVCenter)( + Button( + label = "Login with ORCID to create a Proposal", + icon = Image(src = Resources.OrcidLogo, clazz = LoginStyles.LoginOrcidIcon), + clazz = LoginStyles.LoginBoxButton, + severity = Button.Severity.Secondary, + onClick = ctx.sso.switchToORCID.runAsync + ).big + ) + ) diff --git a/model/shared/src/main/scala/explore/Proposal.scala b/model/shared/src/main/scala/explore/Proposal.scala index 0a7b7eed43..89ff78470b 100644 --- a/model/shared/src/main/scala/explore/Proposal.scala +++ b/model/shared/src/main/scala/explore/Proposal.scala @@ -19,8 +19,8 @@ import monocle.Focus import monocle.Iso import monocle.Lens -import java.time.LocalDateTime import java.time.Duration +import java.time.LocalDateTime case class Proposal( callId: Option[CallForProposals.Id], From e4bc5003ea2d2f0b18c57ca2bd33401ecfad8303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Piaggio?= Date: Wed, 25 Sep 2024 18:57:45 -0300 Subject: [PATCH 3/3] submitted message --- .../main/scala/explore/ExploreLayout.scala | 13 +---- .../explore/SubmittedProposalMessage.scala | 51 +++++++++++++++++++ .../proposal/ProposalTabContents.scala | 3 +- 3 files changed, 53 insertions(+), 14 deletions(-) create mode 100644 explore/src/main/scala/explore/SubmittedProposalMessage.scala diff --git a/explore/src/main/scala/explore/ExploreLayout.scala b/explore/src/main/scala/explore/ExploreLayout.scala index b4ddbe9e80..064d130604 100644 --- a/explore/src/main/scala/explore/ExploreLayout.scala +++ b/explore/src/main/scala/explore/ExploreLayout.scala @@ -23,7 +23,6 @@ import explore.shortcuts.* import explore.shortcuts.given import explore.utils.* import japgolly.scalajs.react.* -import japgolly.scalajs.react.React import japgolly.scalajs.react.extra.router.ResolutionWithProps import japgolly.scalajs.react.extra.router.SetRouteVia import japgolly.scalajs.react.vdom.html_<^.* @@ -35,7 +34,6 @@ import lucuma.react.common.* import lucuma.react.hotkeys.* import lucuma.react.hotkeys.hooks.* import lucuma.react.primereact.ConfirmDialog -import lucuma.react.primereact.Message import lucuma.react.primereact.Sidebar import lucuma.react.primereact.Toast import lucuma.react.primereact.ToastRef @@ -213,11 +211,6 @@ object ExploreLayout: p.deadline(c, piP) .flatten - val deadlineStr: String = - deadline - .map(d => s" until the proposal deadline at ${Proposal.deadlineString(d)}") - .orEmpty - val cacheKey: String = userVault.get .map(_.user) @@ -298,11 +291,7 @@ object ExploreLayout: else <.div(LayoutStyles.MainBody, LayoutStyles.WithMessage.when(isSubmitted))( props.resolution.renderP(props.model), - if (isSubmitted) - Message(text = - s"The proposal has been submitted as ${proposalReference.foldMap(_.label)} and may be retracted${deadlineStr}." - ) - else EmptyVdom + TagMod.when(isSubmitted)(SubmittedProposalMessage(proposalReference, deadline)) ) ) ) diff --git a/explore/src/main/scala/explore/SubmittedProposalMessage.scala b/explore/src/main/scala/explore/SubmittedProposalMessage.scala new file mode 100644 index 0000000000..673f0f3056 --- /dev/null +++ b/explore/src/main/scala/explore/SubmittedProposalMessage.scala @@ -0,0 +1,51 @@ +// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package explore + +import cats.effect.IO +import cats.syntax.all.* +import crystal.react.hooks.* +import explore.model.Proposal +import fs2.Stream +import japgolly.scalajs.react.* +import japgolly.scalajs.react.vdom.html_<^.* +import lucuma.core.model.ProposalReference +import lucuma.core.util.Timestamp +import lucuma.react.common.ReactFnProps +import lucuma.react.primereact.Message + +import scala.concurrent.duration.* + +case class SubmittedProposalMessage( + proposalReference: Option[ProposalReference], + deadline: Option[Timestamp] +) extends ReactFnProps(SubmittedProposalMessage.component): + private val proposalReferenceStr: String = + proposalReference.map(pr => s" as ${pr.label}").orEmpty + + private val deadlineStr: String = + deadline + .map(d => s" until the proposal deadline at ${Proposal.deadlineString(d)}") + .orEmpty + +object SubmittedProposalMessage: + private type Props = SubmittedProposalMessage + + private val component = + ScalaFnComponent + .withHooks[Props] + .useStreamOnMount: + Stream + .fixedRateStartImmediately[IO](1.second) + .evalMap: _ => + IO.monotonic.map(finiteDuration => Timestamp.ofEpochMilli(finiteDuration.toMillis)) + .render: (props, nowPot) => + val retractStr: String = nowPot.toOption.flatten + .filter(now => props.deadline.forall(_ > now)) + .as(s" and may be retracted${props.deadlineStr}") + .orEmpty + + Message(text = + s"The proposal has been submitted${props.proposalReferenceStr}${retractStr}." + ) diff --git a/explore/src/main/scala/explore/proposal/ProposalTabContents.scala b/explore/src/main/scala/explore/proposal/ProposalTabContents.scala index 00ebd31b1b..bb88368d83 100644 --- a/explore/src/main/scala/explore/proposal/ProposalTabContents.scala +++ b/explore/src/main/scala/explore/proposal/ProposalTabContents.scala @@ -111,8 +111,7 @@ object ProposalTabContents: val deadline: Option[Timestamp] = proposalView.get.deadline(props.cfps, piPartner) - <.div( - ExploreStyles.ProposalTab, + <.div(ExploreStyles.ProposalTab)( ProposalEditor( props.programId, props.userVault.map(_.user.id),