From a1ef822deb9380084811d85ca8529ac01c62a2e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Piaggio?= Date: Wed, 18 Sep 2024 09:24:36 -0300 Subject: [PATCH] some more refactoring --- .../scala/explore/proposal/CallDeadline.scala | 43 +++ ...ramUsers.scala => InvestigatorUsers.scala} | 35 +- .../explore/proposal/ProposalEditor.scala | 3 +- .../proposal/ProposalTabContents.scala | 308 +++++++----------- .../explore/users/CreateInviteProcess.scala | 12 + .../{proposal => users}/InviteUserPopup.scala | 2 +- .../users/ProgramUserInvitations.scala | 4 +- 7 files changed, 197 insertions(+), 210 deletions(-) create mode 100644 explore/src/main/scala/explore/proposal/CallDeadline.scala rename explore/src/main/scala/explore/proposal/{ProgramUsers.scala => InvestigatorUsers.scala} (72%) create mode 100644 explore/src/main/scala/explore/users/CreateInviteProcess.scala rename explore/src/main/scala/explore/{proposal => users}/InviteUserPopup.scala (99%) diff --git a/explore/src/main/scala/explore/proposal/CallDeadline.scala b/explore/src/main/scala/explore/proposal/CallDeadline.scala new file mode 100644 index 0000000000..239f06332a --- /dev/null +++ b/explore/src/main/scala/explore/proposal/CallDeadline.scala @@ -0,0 +1,43 @@ +// 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 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) + +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]") + + <.span(ExploreStyles.ProposalDeadline)( + Message( + text = s"Deadline: $text", + severity = Message.Severity.Info + ) + ) diff --git a/explore/src/main/scala/explore/proposal/ProgramUsers.scala b/explore/src/main/scala/explore/proposal/InvestigatorUsers.scala similarity index 72% rename from explore/src/main/scala/explore/proposal/ProgramUsers.scala rename to explore/src/main/scala/explore/proposal/InvestigatorUsers.scala index 0ec0cc57c1..0a5a1dcc8e 100644 --- a/explore/src/main/scala/explore/proposal/ProgramUsers.scala +++ b/explore/src/main/scala/explore/proposal/InvestigatorUsers.scala @@ -3,7 +3,6 @@ package explore.proposal -import cats.data.NonEmptyList import cats.data.NonEmptySet import cats.syntax.all.* import crystal.react.* @@ -12,6 +11,7 @@ import explore.components.Tile import explore.model.ProgramUserWithRole import explore.model.ProposalTabTileIds import explore.model.UserInvitation +import explore.users.CreateInviteProcess import explore.users.ProgramUserInvitations import explore.users.ProgramUsersTable import japgolly.scalajs.react.* @@ -19,7 +19,6 @@ import japgolly.scalajs.react.vdom.html_<^.* import lucuma.core.enums.InvitationStatus import lucuma.core.enums.ProgramUserRole import lucuma.core.model.Program -import lucuma.core.util.Enumerated import lucuma.core.util.NewType import lucuma.react.common.ReactFnProps import lucuma.react.primereact.Button @@ -27,25 +26,19 @@ import lucuma.react.primereact.OverlayPanelRef import lucuma.ui.primereact.* import lucuma.ui.syntax.all.given -enum CreateInviteProcess(private val tag: String) derives Enumerated: - case Idle extends CreateInviteProcess("idle") - case Running extends CreateInviteProcess("running") - case Error extends CreateInviteProcess("error") - case Done extends CreateInviteProcess("done") +case class InvestigatorUsers private ( + pid: Program.Id, + readOnly: Boolean, + users: View[List[ProgramUserWithRole]], + invitations: View[List[UserInvitation]], + private val state: View[InvestigatorUsers.ProgramUsersState] +) extends ReactFnProps(InvestigatorUsers.component) -object ProgramUsersState extends NewType[CreateInviteProcess] -type ProgramUsersState = ProgramUsersState.Type +object InvestigatorUsers: + private type Props = InvestigatorUsers -case class ProgramUsers( - pid: Program.Id, - readOnly: Boolean, - users: View[List[ProgramUserWithRole]], - invitations: View[List[UserInvitation]], - state: View[ProgramUsersState] -) extends ReactFnProps(ProgramUsers.component) - -object ProgramUsers: - private type Props = ProgramUsers + protected object ProgramUsersState extends NewType[CreateInviteProcess] + private type ProgramUsersState = ProgramUsersState.Type private def inviteControl( readOnly: Boolean, @@ -73,7 +66,9 @@ object ProgramUsers: ProposalTabTileIds.UsersId.id, "Investigators", ProgramUsersState(CreateInviteProcess.Idle) - )(ProgramUsers(pid, readOnly, users, invitations, _), (s, _) => inviteControl(readOnly, ref, s)) + )(InvestigatorUsers(pid, readOnly, users, invitations, _), + (s, _) => inviteControl(readOnly, ref, s) + ) private val component = ScalaFnComponent[Props]: props => diff --git a/explore/src/main/scala/explore/proposal/ProposalEditor.scala b/explore/src/main/scala/explore/proposal/ProposalEditor.scala index f3f9bb4e0a..55be73a3d2 100644 --- a/explore/src/main/scala/explore/proposal/ProposalEditor.scala +++ b/explore/src/main/scala/explore/proposal/ProposalEditor.scala @@ -26,6 +26,7 @@ import explore.model.UserInvitation import explore.model.enums.GridLayoutSection import explore.model.layout.LayoutsMap import explore.undo.* +import explore.users.InviteUserPopup import japgolly.scalajs.react.* import japgolly.scalajs.react.vdom.html_<^.* import lucuma.core.enums.ProgramUserRole @@ -130,7 +131,7 @@ object ProposalEditor: ) val usersTile = - ProgramUsers.programUsersTile( + InvestigatorUsers.programUsersTile( props.programId, props.readonly, props.users, diff --git a/explore/src/main/scala/explore/proposal/ProposalTabContents.scala b/explore/src/main/scala/explore/proposal/ProposalTabContents.scala index 7b32bff1b5..ef2cf4bf8b 100644 --- a/explore/src/main/scala/explore/proposal/ProposalTabContents.scala +++ b/explore/src/main/scala/explore/proposal/ProposalTabContents.scala @@ -26,9 +26,7 @@ import explore.model.layout.LayoutsMap import explore.syntax.ui.* import explore.undo.* import explore.utils.* -import fs2.Stream import japgolly.scalajs.react.* -import japgolly.scalajs.react.hooks.Hooks.UseState import japgolly.scalajs.react.vdom.html_<^.* import lucuma.core.enums.ProgramType import lucuma.core.model.Program @@ -54,37 +52,6 @@ import lucuma.ui.syntax.all.given import org.typelevel.log4cats.Logger import queries.common.ProposalQueriesGQL.* -import java.time.Instant -import scala.concurrent.duration.* - -case class CallDeadline(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]") - <.span( - ExploreStyles.ProposalDeadline, - Message( - text = s"Deadline: $text", - severity = Message.Severity.Info - ) - ) - ) - } - case class ProposalTabContents( programId: Program.Id, userVault: Option[UserVault], @@ -97,8 +64,9 @@ case class ProposalTabContents( ) extends ReactFnProps(ProposalTabContents.component) object ProposalTabContents: + private type Props = ProposalTabContents + private object IsUpdatingStatus extends NewType[Boolean] - private type IsUpdatingStatus = IsUpdatingStatus.Type private def createProposal( programId: Program.Id, @@ -117,164 +85,132 @@ object ProposalTabContents: .void .runAsync - private def renderFn( - programId: Program.Id, - userVault: Option[UserVault], - programDetails: View[ProgramDetails], - cfps: List[CallForProposal], - timeEstimateRange: Pot[Option[ProgramTimeRange]], - attachments: View[List[ProposalAttachment]], - undoStacks: View[UndoStacks[IO, Proposal]], - ctx: AppContext[IO], - layout: LayoutsMap, - isUpdatingStatus: View[IsUpdatingStatus], - readonly: Boolean, - errorMessage: UseState[Option[String]] - ): VdomNode = { - import ctx.given - - val invitations = programDetails.zoom(ProgramDetails.invitations) - val users = programDetails.zoom(ProgramDetails.allUsers) - - val isStdUser = userVault.map(_.user).collect { case _: StandardUser => () }.isDefined - val proposalStatus = programDetails.get.proposalStatus - - def updateStatus(newStatus: ProposalStatus): Callback = - (for { - _ <- SetProposalStatus[IO] - .execute(SetProposalStatusInput(programId = 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 - _ <- programDetails.zoom(ProgramDetails.proposalStatus).set(newStatus).toAsync - } yield ()).switching(isUpdatingStatus.async, IsUpdatingStatus(_)).runAsync - - if (programDetails.get.programType =!= ProgramType.Science) - <.div(ExploreStyles.HVCenter, - Message(text = "Only Science Program Types can have proposals.", - severity = Message.Severity.Info - ) - ) - else - programDetails - .zoom(ProgramDetails.proposal) - .mapValue((proposalView: View[Proposal]) => - val piPartner = - programDetails.zoom(ProgramDetails.piPartner.some).get - - val deadline: Option[Timestamp] = - proposalView.get.deadline(cfps, piPartner) - - <.div( - ExploreStyles.ProposalTab, - ProposalEditor( - programId, - userVault.map(_.user.id), - proposalView, - undoStacks, - timeEstimateRange, - users, - invitations, - attachments, - userVault.map(_.token), - cfps, - layout, - readonly - ), - Toolbar(left = - <.div( - ExploreStyles.ProposalSubmissionBar, - Tag( - value = 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 - ).compact.tiny, - deadline.map(CallDeadline.apply) - ) - .when( - isStdUser && proposalStatus === ProposalStatus.NotSubmitted - ), - 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)) - ) - ) - ) - ) - .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( - programId, - 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 - ) - ) - } - - private type Props = 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)) - .render { (props, ctx, isUpdatingStatus, readonly, errorMsg) => - renderFn( - props.programId, - props.userVault, - props.programDetails, - props.cfps, - props.timeEstimateRange, - props.attachments, - props.undoStacks, - ctx, - props.layout, - isUpdatingStatus, - readonly, - errorMsg - ) - } + .render: (props, ctx, isUpdatingStatus, readonly, errorMessage) => + + import ctx.given + + val invitations = props.programDetails.zoom(ProgramDetails.invitations) + val users = props.programDetails.zoom(ProgramDetails.allUsers) + + val isStdUser = props.userVault.map(_.user).collect { case _: StandardUser => () }.isDefined + val 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 + + 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) + + <.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 + ).compact.tiny, + deadline.map(CallDeadline.apply) + ) + .when: + isStdUser && proposalStatus === ProposalStatus.NotSubmitted + , + 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)) + ) + ) + ) + ) + .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/explore/src/main/scala/explore/users/CreateInviteProcess.scala b/explore/src/main/scala/explore/users/CreateInviteProcess.scala new file mode 100644 index 0000000000..9ae41333ea --- /dev/null +++ b/explore/src/main/scala/explore/users/CreateInviteProcess.scala @@ -0,0 +1,12 @@ +// 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.users + +import lucuma.core.util.Enumerated + +enum CreateInviteProcess(private val tag: String) derives Enumerated: + case Idle extends CreateInviteProcess("idle") + case Running extends CreateInviteProcess("running") + case Error extends CreateInviteProcess("error") + case Done extends CreateInviteProcess("done") diff --git a/explore/src/main/scala/explore/proposal/InviteUserPopup.scala b/explore/src/main/scala/explore/users/InviteUserPopup.scala similarity index 99% rename from explore/src/main/scala/explore/proposal/InviteUserPopup.scala rename to explore/src/main/scala/explore/users/InviteUserPopup.scala index 8dced2e08c..50f5f635ea 100644 --- a/explore/src/main/scala/explore/proposal/InviteUserPopup.scala +++ b/explore/src/main/scala/explore/users/InviteUserPopup.scala @@ -1,7 +1,7 @@ // 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 +package explore.users import cats.effect.IO import cats.syntax.all.* diff --git a/explore/src/main/scala/explore/users/ProgramUserInvitations.scala b/explore/src/main/scala/explore/users/ProgramUserInvitations.scala index b96ad868e0..22e18548a6 100644 --- a/explore/src/main/scala/explore/users/ProgramUserInvitations.scala +++ b/explore/src/main/scala/explore/users/ProgramUserInvitations.scala @@ -29,7 +29,7 @@ import lucuma.ui.syntax.all.given import lucuma.ui.table.* import queries.common.InvitationQueriesGQL.* -case class ProgramUserInvitations(invitations: View[List[UserInvitation]], readOnly: Boolean) +case class ProgramUserInvitations(invitations: View[List[UserInvitation]], readonly: Boolean) extends ReactFnProps(ProgramUserInvitations.component) object ProgramUserInvitations: @@ -132,7 +132,7 @@ object ProgramUserInvitations: meta = TableMeta( isActive = isActive, invitations = props.invitations, - readOnly = props.readOnly + readOnly = props.readonly ) ) .render: (props, _, _, _, _, table) =>