diff --git a/common/src/main/scala/explore/components/ui/ExploreStyles.scala b/common/src/main/scala/explore/components/ui/ExploreStyles.scala index eb240afe26..8a426a5aa3 100644 --- a/common/src/main/scala/explore/components/ui/ExploreStyles.scala +++ b/common/src/main/scala/explore/components/ui/ExploreStyles.scala @@ -429,6 +429,9 @@ object ExploreStyles: val TargetSearchPreviewPlaceholder: Css = Css("explore-target-search-preview-placeholder") val TargetSearchResults: Css = Css("explore-target-search-results") + // Configuration Request Editor Popup + val ConfigurationRequestEditorPopup: Css = Css("explore-config-request-editor") + // Aladin Target classes val ScienceTarget: Css = Css("science-target") val ScienceSelectedTarget: Css = Css("science-selected-target") diff --git a/common/src/main/scala/explore/model/ExploreGridLayouts.scala b/common/src/main/scala/explore/model/ExploreGridLayouts.scala index bca0c83977..b647d459c1 100644 --- a/common/src/main/scala/explore/model/ExploreGridLayouts.scala +++ b/common/src/main/scala/explore/model/ExploreGridLayouts.scala @@ -305,9 +305,10 @@ object ExploreGridLayouts: end observationList object programs: - private lazy val DetailsHeight: NonNegInt = 6.refined - private lazy val NotesHeight: NonNegInt = 6.refined - private lazy val ChangeRequestsHeight: NonNegInt = 6.refined + private lazy val DetailsHeight: NonNegInt = 6.refined + private lazy val NotesHeight: NonNegInt = 6.refined + private lazy val ChangeRequestsHeight: NonNegInt = 6.refined + private lazy val UnrequestedConfigsHeight: NonNegInt = 6.refined private lazy val layoutMedium: Layout = Layout( List( @@ -331,6 +332,13 @@ object ExploreGridLayouts: y = (DetailsHeight |+| NotesHeight).value, w = DefaultWidth.value, h = ChangeRequestsHeight.value + ), + LayoutItem( + i = ProgramTabTileIds.UnrequestedConfigsId.id.value, + x = 0, + y = (DetailsHeight |+| NotesHeight |+| ChangeRequestsHeight).value, + w = DefaultWidth.value, + h = UnrequestedConfigsHeight.value ) ) ) diff --git a/common/src/main/webapp/sass/explore.scss b/common/src/main/webapp/sass/explore.scss index 3fb6627ec1..a04eb90f02 100644 --- a/common/src/main/webapp/sass/explore.scss +++ b/common/src/main/webapp/sass/explore.scss @@ -1994,6 +1994,20 @@ svg.fa-triangle-exclamation.explore-error-icon { vertical-align: middle; } +// ------- +// Configuration Request Editor Popup +// ------- +.explore-config-request-editor { + textarea { + width: 100%; + height: 9rem; + } + + .p-dialog-footer .p-button:first-child { + float: left; + } +} + // ------ // Proposals and Partner Splits // ------ diff --git a/explore/src/main/scala/explore/Routing.scala b/explore/src/main/scala/explore/Routing.scala index fad5cd31e3..ab63118166 100644 --- a/explore/src/main/scala/explore/Routing.scala +++ b/explore/src/main/scala/explore/Routing.scala @@ -169,8 +169,10 @@ object Routing: yield ProgramTabContents( routingInfo.programId, programDetails, - programSummaries.get.configurationRequests, + programSummaries.model.zoom(ProgramSummaries.configurationRequests), + programSummaries.model.zoom(ProgramSummaries.observations), programSummaries.get.obs4ConfigRequests, + programSummaries.get.configsWithoutRequests, programSummaries.get.targets, model.rootModel.zoom(RootModel.vault).get, programSummaries.get.programTimesPot, diff --git a/explore/src/main/scala/explore/model/AppContext.scala b/explore/src/main/scala/explore/model/AppContext.scala index 49d2ef5c90..83348e6cbd 100644 --- a/explore/src/main/scala/explore/model/AppContext.scala +++ b/explore/src/main/scala/explore/model/AppContext.scala @@ -19,7 +19,9 @@ import fs2.dom.BroadcastChannel import japgolly.scalajs.react.* import japgolly.scalajs.react.extra.router.SetRouteVia import japgolly.scalajs.react.feature.Context +import japgolly.scalajs.react.vdom.html_<^.* import lucuma.core.enums.ExecutionEnvironment +import lucuma.core.model.Observation import lucuma.core.model.Program import lucuma.react.primereact.ToastRef import lucuma.schemas.ObservationDB @@ -53,6 +55,27 @@ case class AppContext[F[_]]( def replacePage(appTab: AppTab, programId: Program.Id, focused: Focused): Callback = setPageVia(appTab, programId, focused, SetRouteVia.HistoryReplace) + def routingLink( + appTab: AppTab, + programId: Program.Id, + focused: Focused, + text: String, + via: SetRouteVia = SetRouteVia.HistoryPush + ): VdomNode = + <.a(^.href := pageUrl(appTab, programId, focused), + ^.onClick ==> (e => + e.preventDefaultCB >> e.stopPropagationCB >> + setPageVia(appTab, programId, focused, via) + ) + )(text) + + def obsIdRoutingLink( + programId: Program.Id, + obsId: Observation.Id, + via: SetRouteVia = SetRouteVia.HistoryPush + ): VdomNode = + routingLink(AppTab.Observations, programId, Focused.singleObs(obsId), obsId.show) + given WebSocketJSClient[F, ObservationDB] = clients.odb given WebSocketJSClient[F, UserPreferencesDB] = clients.preferencesDB given FetchJSClient[F, SSO] = clients.sso diff --git a/explore/src/main/scala/explore/observationtree/ObsSummaryTable.scala b/explore/src/main/scala/explore/observationtree/ObsSummaryTable.scala index 4c9b8375c1..b200492806 100644 --- a/explore/src/main/scala/explore/observationtree/ObsSummaryTable.scala +++ b/explore/src/main/scala/explore/observationtree/ObsSummaryTable.scala @@ -186,55 +186,20 @@ object ObsSummaryTable: ctx.pushPage(AppTab.Constraints, props.programId, Focused.singleObs(constraintId)) def targetLink(obsId: Observation.Id, tWId: TargetWithId): VdomNode = - <.a( - ^.href := ctx.pageUrl( - AppTab.Observations, - props.programId, - Focused.singleObs(obsId, tWId.id.some) - ), - ^.onClick ==> (e => - e.preventDefaultCB >> e.stopPropagationCB >> - ctx.pushPage( - AppTab.Observations, - props.programId, - Focused.singleObs(obsId, tWId.id.some) - ) - ) - )(tWId.target.name.value) + val text = tWId.target.name.value + ctx.routingLink( + AppTab.Observations, + props.programId, + Focused.singleObs(obsId, tWId.id.some), + text + ) def obsLink(obsId: Observation.Id): VdomNode = - <.a( - ^.href := ctx.pageUrl( - AppTab.Observations, - props.programId, - Focused.singleObs(obsId) - ), - ^.onClick ==> { (e: ReactMouseEvent) => - e.preventDefaultCB >> e.stopPropagationCB >> - ctx.pushPage( - AppTab.Observations, - props.programId, - Focused.singleObs(obsId) - ) - } - )(obsId.toString) + ctx.obsIdRoutingLink(props.programId, obsId) def groupLink(group: Group): VdomNode = - <.a( - ^.href := ctx.pageUrl( - AppTab.Observations, - props.programId, - Focused.group(group.id) - ), - ^.onClick ==> { (e: ReactMouseEvent) => - e.preventDefaultCB >> e.stopPropagationCB >> - ctx.pushPage( - AppTab.Observations, - props.programId, - Focused.group(group.id) - ) - } - )(group.name.map(_.toString).getOrElse(group.id.toString)) + val text = group.name.map(_.toString).getOrElse(group.id.toString) + ctx.routingLink(AppTab.Observations, props.programId, Focused.group(group.id), text) List( ColDef( diff --git a/explore/src/main/scala/explore/programs/ConfigurationRequestEditorPopup.scala b/explore/src/main/scala/explore/programs/ConfigurationRequestEditorPopup.scala new file mode 100644 index 0000000000..e263ff8547 --- /dev/null +++ b/explore/src/main/scala/explore/programs/ConfigurationRequestEditorPopup.scala @@ -0,0 +1,94 @@ +// 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.programs + +import cats.syntax.all.* +import crystal.react.hooks.* +import explore.Icons +import explore.components.ui.ExploreStyles +import explore.model.AppContext +import japgolly.scalajs.react.* +import japgolly.scalajs.react.vdom.html_<^.* +import lucuma.core.util.NewType +import lucuma.react.common.ReactFnProps +import lucuma.react.primereact.Button +import lucuma.react.primereact.Dialog +import lucuma.react.primereact.DialogPosition +import lucuma.react.primereact.Divider +import lucuma.refined.* +import lucuma.ui.primereact.* +import lucuma.ui.primereact.given + +case class ConfigurationRequestEditorPopup( + trigger: Button, + onSubmit: String => Callback +) extends ReactFnProps(ConfigurationRequestEditorPopup.component) + +object ConfigurationRequestEditorPopup: + private type Props = ConfigurationRequestEditorPopup + + private object PopupState extends NewType[Boolean]: + inline def Open: PopupState = PopupState(true) + inline def Closed: PopupState = PopupState(false) + + private type PopupState = PopupState.Type + + val component = ScalaFnComponent + .withHooks[Props] + .useContext(AppContext.ctx) + .useStateView(PopupState.Closed) + .useStateView("") // message + .render: (props, ctx, popupState, message) => + val close = popupState.set(PopupState.Closed) + + val notice = + """Please briefly describe and justify the requested changes to the approved + |coordinates + instrument configurations + constraints. These changes will + |be approved by the Head of Science Operations at the site of the observations. + """.stripMargin.linesIterator.mkString(" ") + + val footer = React.Fragment( + Button( + label = "Clear Text", + icon = Icons.Eraser, + disabled = message.get.isEmpty, + onClick = message.set("") + ).small, + Button( + label = "Cancel", + icon = Icons.Close, + severity = Button.Severity.Danger, + onClick = close + ).small, + Button( + label = "Submit", + icon = Icons.PaperPlaneTop, + disabled = message.get.isBlank, + onClick = close >> props.onSubmit(message.get) + ).small + ) + + React.Fragment( + props.trigger.copy(onClick = props.trigger.onClick >> popupState.set(PopupState.Open)), + Dialog( + visible = popupState.get.value, + onHide = close, + closable = true, + closeOnEscape = true, + dismissableMask = true, + resizable = true, + clazz = LucumaPrimeStyles.Dialog.Small |+| ExploreStyles.ConfigurationRequestEditorPopup, + header = "Request Editor", + footer = footer + )( + <.div( + notice, + Divider(), + FormInputTextAreaView( + id = "message_text_area".refined, + value = message + ) + ) + ) + ) diff --git a/explore/src/main/scala/explore/programs/ConfigurationTableColumnBuilder.scala b/explore/src/main/scala/explore/programs/ConfigurationTableColumnBuilder.scala new file mode 100644 index 0000000000..3da1e123a5 --- /dev/null +++ b/explore/src/main/scala/explore/programs/ConfigurationTableColumnBuilder.scala @@ -0,0 +1,139 @@ +// 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.programs + +import cats.Order.given +import cats.effect.IO +import cats.syntax.all.* +import explore.model.AppContext +import explore.model.Observation +import explore.model.TargetList +import japgolly.scalajs.react.* +import japgolly.scalajs.react.vdom.html_<^.* +import lucuma.core.math.validation.MathValidators +import lucuma.core.model.Configuration +import lucuma.core.model.Configuration.ObservingMode.GmosNorthLongSlit +import lucuma.core.model.Configuration.ObservingMode.GmosSouthLongSlit +import lucuma.core.model.Program +import lucuma.react.syntax.* +import lucuma.react.table.* +import lucuma.react.table.ColumnDef +import lucuma.react.table.ColumnId +import lucuma.ui.syntax.table.* + +import scala.collection.immutable.SortedSet + +case class ConfigurationTableColumnBuilder[D, TM](colDef: ColumnDef.Applied[D, TM]): + import ConfigurationTableColumnBuilder.* + + def configurationColumns(getConfiguration: D => Configuration) = + def configurationColumn[V]( + id: ColumnId, + accessor: Configuration => V + ) = colDef(id, r => accessor(getConfiguration(r)), ColumnNames(id)) + + List( + configurationColumn(RAColumnId, _.refererenceCoordinates.ra) + .setCell(c => MathValidators.truncatedRA.reverseGet(c.value)) + .setSize(110.toPx) + .sortable, + configurationColumn(DecColumnId, _.refererenceCoordinates.dec) + .setCell(c => MathValidators.truncatedDec.reverseGet(c.value)) + .setSize(110.toPx) + .sortable, + configurationColumn(InstrumentColumnId, _.observingMode.tpe.instrument.shortName) + .setSize(110.toPx) + .sortable, + configurationColumn(FPUColumnId, _.observingMode.fpu).setSize(110.toPx).sortable, + configurationColumn(DisperserColumnId, _.observingMode.disperser) + .setSize(110.toPx) + .sortable, + configurationColumn(ImageQualityColumnId, _.conditions.imageQuality) + .setCell(_.value.label) + .setSize(80.toPx) + .sortable, + configurationColumn(CloudExtinctionColumnId, _.conditions.cloudExtinction) + .setCell(_.value.label) + .setSize(80.toPx) + .sortable, + configurationColumn(SkyBackgroundColumnId, _.conditions.skyBackground) + .setCell(_.value.label) + .setSize(80.toPx) + .sortable, + configurationColumn(WaterVaporColumnId, _.conditions.waterVapor) + .setCell(_.value.label) + .setSize(80.toPx) + .sortable + ) + + def obsListColumn( + accessor: D => SortedSet[Observation.Id], + programId: Program.Id, + ctx: AppContext[IO] + ) = + colDef(ObservationsColumnId, accessor, ColumnNames(ObservationsColumnId)) + .setCell(c => + <.span( + c.value.toList + .map(obsId => ctx.obsIdRoutingLink(programId, obsId)) + .mkReactFragment(", ") + ) + ) + .setSize(150.toPx) + .setEnableSorting(false) + + def targetColumn(accessor: D => String) = + colDef(TargetColumnId, accessor, ColumnNames(TargetColumnId)) + .setSize(150.toPx) + .sortable + +object ConfigurationTableColumnBuilder { + private val TargetColumnId = ColumnId("target") + private val RAColumnId = ColumnId("ra") + private val DecColumnId = ColumnId("dec") + private val InstrumentColumnId = ColumnId("instrument") + private val FPUColumnId = ColumnId("fpu") + private val DisperserColumnId = ColumnId("disperser") + private val ImageQualityColumnId = ColumnId("image_quality") + private val CloudExtinctionColumnId = ColumnId("cloud_extinction") + private val SkyBackgroundColumnId = ColumnId("sky_background") + private val WaterVaporColumnId = ColumnId("water_vapor") + private val ObservationsColumnId = ColumnId("observations") + + val ColumnNames: Map[ColumnId, String] = Map( + TargetColumnId -> "Target", + RAColumnId -> "RA", + DecColumnId -> "Dec", + InstrumentColumnId -> "Instrument", + FPUColumnId -> "FPU", + DisperserColumnId -> "Disperser", + ImageQualityColumnId -> "IQ", + CloudExtinctionColumnId -> "CC", + SkyBackgroundColumnId -> "SB", + WaterVaporColumnId -> "WV", + ObservationsColumnId -> "Obs" + ) + + extension (mode: Configuration.ObservingMode) + def fpu: String = mode match + case GmosNorthLongSlit(_) => "LongSlit" + case GmosSouthLongSlit(_) => "LongSlit" + def disperser: String = mode match + case GmosNorthLongSlit(grating) => grating.shortName + case GmosSouthLongSlit(grating) => grating.shortName + + def targetName(observations: List[Observation], targets: TargetList): String = + val targetNames = + observations + .flatMap(_.scienceTargetIds) + .distinct + .map(tid => targets.get(tid).map(_.name.value)) + .flattenOption + .distinct + targetNames match + case head :: Nil => head + case head :: next => "" + case Nil => "" + +} diff --git a/explore/src/main/scala/explore/programs/ProgramConfigRequestsTile.scala b/explore/src/main/scala/explore/programs/ProgramConfigRequestsTile.scala index ea139ea538..13a49e959b 100644 --- a/explore/src/main/scala/explore/programs/ProgramConfigRequestsTile.scala +++ b/explore/src/main/scala/explore/programs/ProgramConfigRequestsTile.scala @@ -4,23 +4,16 @@ package explore.programs import cats.Order.given -import cats.syntax.all.* import explore.Icons import explore.model.AppContext import explore.model.ConfigurationRequestList -import explore.model.Focused import explore.model.Observation import explore.model.TargetList import explore.model.display.given -import explore.model.enums.AppTab import explore.model.reusability.given import japgolly.scalajs.react.* import japgolly.scalajs.react.vdom.html_<^.* import lucuma.core.enums.ConfigurationRequestStatus -import lucuma.core.math.validation.MathValidators -import lucuma.core.model.Configuration -import lucuma.core.model.Configuration.ObservingMode.GmosNorthLongSlit -import lucuma.core.model.Configuration.ObservingMode.GmosSouthLongSlit import lucuma.core.model.ConfigurationRequest import lucuma.core.model.Program import lucuma.core.syntax.all.* @@ -64,59 +57,22 @@ object ProgramConfigRequestsTile: obs4ConfigRequests: Map[ConfigurationRequest.Id, List[Observation]], targets: TargetList ): Row = - val obses = obs4ConfigRequests.get(request.id).getOrElse(List.empty) - val obsIds = SortedSet.from(obses.map(_.id)) - val targetNames = - obses - .flatMap(_.scienceTargetIds) - .distinct - .map(tid => targets.get(tid).map(_.name.value)) - .flattenOption - .distinct - val targetName = targetNames match - case head :: Nil => head - case head :: next => "" - case Nil => "" + val obses = obs4ConfigRequests.get(request.id).getOrElse(List.empty) + val obsIds = SortedSet.from(obses.map(_.id)) + val targetName = ConfigurationTableColumnBuilder.targetName(obses, targets) Row(request, obsIds, targetName) - private val ColDef = ColumnDef[Row] + private val ColDef = ColumnDef[Row] + private val columnBuilder = ConfigurationTableColumnBuilder(ColDef) private val ConfigRequestIdColumnId = ColumnId("config_request_id") - private val TargetColumnId = ColumnId("target") - private val RAColumnId = ColumnId("ra") - private val DecColumnId = ColumnId("dec") - private val InstrumentColumnId = ColumnId("instrument") - private val FPUColumnId = ColumnId("fpu") - private val DisperserColumnId = ColumnId("disperser") - private val ImageQualityColumnId = ColumnId("image_quality") - private val CloudExtinctionColumnId = ColumnId("cloud_extinction") - private val SkyBackgroundColumnId = ColumnId("sky_background") - private val WaterVaporColumnId = ColumnId("water_vapor") - private val ObservationsColumnId = ColumnId("observations") private val StatusColumnId = ColumnId("status") val ColumnNames: Map[ColumnId, String] = Map( ConfigRequestIdColumnId -> "ID", - TargetColumnId -> "Target", - RAColumnId -> "RA", - DecColumnId -> "Dec", - InstrumentColumnId -> "Instrument", - FPUColumnId -> "FPU", - DisperserColumnId -> "Disperser", - ImageQualityColumnId -> "IQ", - CloudExtinctionColumnId -> "CC", - SkyBackgroundColumnId -> "SB", - WaterVaporColumnId -> "WV", - ObservationsColumnId -> "Obs", StatusColumnId -> "Status" ) - private def configurationColumn[V]( - id: ColumnId, - accessor: Configuration => V - ): ColumnDef.Single.NoMeta[Row, V] = - ColDef(id, r => accessor(r.request.configuration), ColumnNames(id)) - private def rowColumn[V]( id: ColumnId, accessor: Row => V @@ -131,83 +87,24 @@ object ProgramConfigRequestsTile: <.span(Icons.CircleSolid.withClass(style)) .withTooltip(content = status.shortName, position = Tooltip.Position.Left) - extension (mode: Configuration.ObservingMode) - def fpu: String = mode match - case GmosNorthLongSlit(_) => "LongSlit" - case GmosSouthLongSlit(_) => "LongSlit" - def disperser: String = mode match - case GmosNorthLongSlit(grating) => grating.shortName - case GmosSouthLongSlit(grating) => grating.shortName - val component = ScalaFnComponent .withHooks[Props] .useContext(AppContext.ctx) .useMemoBy((_, _) => ()): (props, ctx) => // Columns _ => - def obsUrl(obsId: Observation.Id): String = - ctx.pageUrl(AppTab.Observations, props.programId, Focused.singleObs(obsId)) - - def goToObs(obsId: Observation.Id): Callback = - ctx.pushPage(AppTab.Observations, props.programId, Focused.singleObs(obsId)) - List( rowColumn(ConfigRequestIdColumnId, _.request.id).setSize(90.toPx).sortable, - rowColumn(TargetColumnId, _.targetName).setSize(150.toPx).sortable, - configurationColumn(RAColumnId, _.refererenceCoordinates.ra) - .setCell(c => MathValidators.truncatedRA.reverseGet(c.value)) - .setSize(110.toPx) - .sortable, - configurationColumn(DecColumnId, _.refererenceCoordinates.dec) - .setCell(c => MathValidators.truncatedDec.reverseGet(c.value)) - .setSize(110.toPx) - .sortable, - configurationColumn(InstrumentColumnId, _.observingMode.tpe.instrument.shortName) - .setSize(110.toPx) - .sortable, - configurationColumn(FPUColumnId, _.observingMode.fpu).setSize(110.toPx).sortable, - configurationColumn(DisperserColumnId, _.observingMode.disperser) - .setSize(110.toPx) - .sortable, - configurationColumn(ImageQualityColumnId, _.conditions.imageQuality) - .setCell(_.value.label) - .setSize(80.toPx) - .sortable, - configurationColumn(CloudExtinctionColumnId, _.conditions.cloudExtinction) - .setCell(_.value.label) - .setSize(80.toPx) - .sortable, - configurationColumn(SkyBackgroundColumnId, _.conditions.skyBackground) - .setCell(_.value.label) - .setSize(80.toPx) - .sortable, - configurationColumn(WaterVaporColumnId, _.conditions.waterVapor) - .setCell(_.value.label) - .setSize(80.toPx) - .sortable, - rowColumn(ObservationsColumnId, _.obsIds) - .setCell(c => - <.span( - c.value.toList - .map(obsId => - <.a( - ^.href := obsUrl(obsId), - ^.onClick ==> (e => - e.preventDefaultCB >> e.stopPropagationCB >> - goToObs(obsId) - ), - obsId.show - ) - ) - .mkReactFragment(", ") - ) - ) - .setSize(150.toPx) - .setEnableSorting(false), - rowColumn(StatusColumnId, _.request.status) - .setCell(c => stateIcon(c.value)) - .setSize(80.toPx) - .sortable - ) + columnBuilder.targetColumn(_.targetName) + ) ++ + columnBuilder.configurationColumns(_.request.configuration) ++ + List( + columnBuilder + .obsListColumn(_.obsIds, props.programId, ctx), + rowColumn(StatusColumnId, _.request.status) + .setCell(c => stateIcon(c.value)) + .setSize(80.toPx) + .sortable + ) .useMemoBy((props, _, _) => // Rows (props.configRequests, props.obs4ConfigRequests, props.targets) ): (_, _, _) => diff --git a/explore/src/main/scala/explore/programs/ProgramUnrequestedConfigsTable.scala b/explore/src/main/scala/explore/programs/ProgramUnrequestedConfigsTable.scala new file mode 100644 index 0000000000..5cd4aa8d8b --- /dev/null +++ b/explore/src/main/scala/explore/programs/ProgramUnrequestedConfigsTable.scala @@ -0,0 +1,206 @@ +// 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.programs + +import cats.Order.given +import cats.data.NonEmptyList +import cats.effect.IO +import cats.syntax.all.* +import crystal.react.View +import crystal.react.hooks.* +import crystal.react.syntax.all.* +import explore.Icons +import explore.components.ui.ExploreStyles +import explore.model.AppContext +import explore.model.ConfigurationRequestList +import explore.model.IsActive +import explore.model.Observation +import explore.model.ObservationList +import explore.model.TargetList +import explore.model.reusability.given +import explore.syntax.ui.* +import japgolly.scalajs.react.* +import japgolly.scalajs.react.vdom.html_<^.* +import lucuma.core.model.Configuration +import lucuma.core.model.Program +import lucuma.react.common.ReactFnProps +import lucuma.react.primereact.Button +import lucuma.react.resizeDetector.hooks.* +import lucuma.react.syntax.* +import lucuma.react.table.* +import lucuma.react.table.ColumnDef +import lucuma.ui.primereact.* +import lucuma.ui.reusability.given +import lucuma.ui.syntax.table.* +import lucuma.ui.table.* +import monocle.Focus +import monocle.Lens +import queries.schemas.odb.ObsQueries + +import scala.collection.immutable.SortedSet + +object ProgramUnrequestedConfigsTable: + case class Row( + configuration: Configuration, + obsIds: SortedSet[Observation.Id], + targetName: String + ): + val id = configuration.toString + + object Row: + def apply( + configuration: Configuration, + observations: NonEmptyList[Observation], + targets: TargetList + ): Row = + val obsIds = SortedSet.from(observations.map(_.id).toList) + val targetName = ConfigurationTableColumnBuilder.targetName(observations.toList, targets) + Row(configuration, obsIds, targetName) + + case class TileState(table: Option[Table[Row, Nothing]], selected: List[RowId]): + def selectedRows: List[Row] = + table.foldMap(t => selected.map(id => t.getRow(id.value).original)) + + object TileState: + val Empty: TileState = TileState(none, List.empty) + + val table = Focus[TileState](_.table) + val selected = Focus[TileState](_.selected) + + case class Body( + programId: Program.Id, + configRequests: View[ConfigurationRequestList], + configsWithoutRequests: Map[Configuration, NonEmptyList[Observation]], + targets: TargetList, + tileState: View[TileState] + ) extends ReactFnProps(Body.component) + + object Body: + private type Props = Body + + given Reusability[Map[Configuration, NonEmptyList[Observation]]] = Reusability.map + + private val ColDef = ColumnDef[Row] + private def columnBuilder = ConfigurationTableColumnBuilder(ColDef) + + val component = ScalaFnComponent + .withHooks[Props] + .useContext(AppContext.ctx) + .useMemoBy((_, _) => ()): (props, ctx) => // Columns + _ => + columnBuilder.targetColumn(_.targetName) :: + (columnBuilder.configurationColumns(_.configuration) :+ + columnBuilder.obsListColumn(_.obsIds, props.programId, ctx)) + .useMemoBy((props, _, _) => (props.configsWithoutRequests, props.targets)): (_, _, _) => + (configs, targets) => configs.toList.map((c, os) => Row(c, os, targets)) + .useReactTableBy: (props, _, columns, rows) => + def rowSelection2RowIds: RowSelection => List[RowId] = selection => + selection.value + .filter(_._2) + .keys + .toList + + def rowIds2RowSelection: List[RowId] => RowSelection = rowIds => + RowSelection: + rowIds.map(_ -> true).toMap + + TableOptions( + columns, + rows, + getRowId = (row, _, _) => RowId(row.id), + enableMultiRowSelection = true, + state = PartialTableState( + rowSelection = rowIds2RowSelection(props.tileState.get.selected) + ), + onRowSelectionChange = (u: Updater[RowSelection]) => + u match + case Updater.Set(selection) => + props.tileState.zoom(TileState.selected).set(rowSelection2RowIds(selection)) + case Updater.Mod(f) => + props.tileState + .zoom(TileState.selected) + .mod: rowIds => + rowSelection2RowIds(f(rowIds2RowSelection(rowIds))) + ) + .useEffectOnMountBy((props, _, _, _, table) => + props.tileState.zoom(TileState.table).set(table.some) + ) + .useResizeDetector() + .render: (props, ctx, _, rows, table, resizer) => + PrimeAutoHeightVirtualizedTable( + table, + _ => 32.toPx, + striped = true, + compact = Compact.Very, + innerContainerMod = ^.width := "100%", + containerRef = resizer.ref, + tableMod = ExploreStyles.ExploreTable |+| ExploreStyles.ExploreSelectableTable, + hoverableRows = rows.nonEmpty, + rowMod = row => + TagMod( + ExploreStyles.TableRowSelected.when(row.getIsSelected()), + ^.onClick ==> table + .getMultiRowSelectedHandler(RowId(row.original.id)) + ), + emptyMessage = <.div("There are no observations without requests.") + ) + + case class Title( + configRequests: View[ConfigurationRequestList], + observations: View[ObservationList], + tileState: TileState + ) extends ReactFnProps(Title.component) + + object Title: + private type Props = Title + + private val component = ScalaFnComponent + .withHooks[Props] + .useContext(AppContext.ctx) + .useStateView(IsActive(false)) + .render: (props, ctx, isActive) => + import ctx.given + + def submitOne(row: Row): IO[Unit] = + ObsQueries + .createConfigurationRequest[IO](row.obsIds.head) + .flatMap: request => + (props.configRequests.mod(_.updated(request.id, request)) >> + props.observations.mod( + _.mapValues(_.id, _.updateToPendingIfConfigurationApplies(request.configuration)) + )).to[IO] + + props.tileState.table.map: table => + def submitRequests(msg: String): Callback = + CallbackTo(props.tileState.selectedRows) + .flatMap( + _.traverse_(submitOne) + .switching(isActive.async, IsActive(_)) + .runAsync + ) >> + table.toggleAllRowsSelected(false) + + React.Fragment( + Button( + size = Button.Size.Small, + icon = Icons.CheckDouble, + label = "All", + onClick = table.toggleAllRowsSelected(true) + ).compact, + Button( + size = Button.Size.Small, + icon = Icons.SquareXMark, + label = "None", + onClick = table.toggleAllRowsSelected(false) + ).compact, + ConfigurationRequestEditorPopup( + onSubmit = submitRequests, + trigger = Button( + size = Button.Size.Small, + icon = Icons.PaperPlaneTop, + label = "Request Approval", + disabled = props.tileState.selected.isEmpty || isActive.get.value + ).compact + ) + ) diff --git a/explore/src/main/scala/explore/tabs/ProgramTabContents.scala b/explore/src/main/scala/explore/tabs/ProgramTabContents.scala index 5ad5be4a12..78d3de85ca 100644 --- a/explore/src/main/scala/explore/tabs/ProgramTabContents.scala +++ b/explore/src/main/scala/explore/tabs/ProgramTabContents.scala @@ -3,6 +3,7 @@ package explore.tabs +import cats.data.NonEmptyList import cats.effect.IO import cats.syntax.option.* import crystal.Pot @@ -15,6 +16,7 @@ import explore.model.AppContext import explore.model.ConfigurationRequestList import explore.model.ExploreGridLayouts import explore.model.Observation +import explore.model.ObservationList import explore.model.ProgramDetails import explore.model.ProgramTabTileIds import explore.model.ProgramTimes @@ -25,8 +27,10 @@ import explore.model.layout.LayoutsMap import explore.programs.ProgramConfigRequestsTile import explore.programs.ProgramDetailsTile import explore.programs.ProgramNotesTile +import explore.programs.ProgramUnrequestedConfigsTable import japgolly.scalajs.react.* import japgolly.scalajs.react.vdom.html_<^.* +import lucuma.core.model.Configuration import lucuma.core.model.ConfigurationRequest import lucuma.core.model.Program import lucuma.core.model.Semester @@ -37,15 +41,17 @@ import lucuma.ui.sso.UserVault import lucuma.ui.syntax.all.given case class ProgramTabContents( - programId: Program.Id, - programDetails: View[ProgramDetails], - configRequests: ConfigurationRequestList, - obs4ConfigRequests: Map[ConfigurationRequest.Id, List[Observation]], - targets: TargetList, - userVault: Option[UserVault], - programTimes: Pot[ProgramTimes], - semester: Semester, - userPreferences: UserPreferences + programId: Program.Id, + programDetails: View[ProgramDetails], + configRequests: View[ConfigurationRequestList], + observations: View[ObservationList], + obs4ConfigRequests: Map[ConfigurationRequest.Id, List[Observation]], + configsWithoutRequests: Map[Configuration, NonEmptyList[Observation]], + targets: TargetList, + userVault: Option[UserVault], + programTimes: Pot[ProgramTimes], + semester: Semester, + userPreferences: UserPreferences ) extends ReactFnProps(ProgramTabContents.component) object ProgramTabContents: @@ -86,16 +92,33 @@ object ProgramTabContents: val configurationRequestsTile = Tile( ProgramTabTileIds.ChangeRequestsId.id, - s"Requested Coordinates + Configurations + Constraints (${props.configRequests.size})" + s"Requested Coordinates + Configurations + Constraints (${props.configRequests.get.size})" )(_ => ProgramConfigRequestsTile( props.programId, - props.configRequests, + props.configRequests.get, props.obs4ConfigRequests, props.targets ) ) + val unrequestedConfigsTile = + Tile( + ProgramTabTileIds.UnrequestedConfigsId.id, + s"Unrequested Coordinates + Configurations + Constraints (${props.configsWithoutRequests.size})", + initialState = ProgramUnrequestedConfigsTable.TileState.Empty + )( + ProgramUnrequestedConfigsTable.Body( + props.programId, + props.configRequests, + props.configsWithoutRequests, + props.targets, + _ + ), + (s, _) => + ProgramUnrequestedConfigsTable.Title(props.configRequests, props.observations, s.get) + ) + <.div(ExploreStyles.MultiPanelTile)( TileController( userVault.user.id.some, @@ -105,7 +128,8 @@ object ProgramTabContents: List( detailsTile, notesTile, - configurationRequestsTile + configurationRequestsTile, + unrequestedConfigsTile ), GridLayoutSection.ProgramsLayout ) diff --git a/explore/src/main/scala/explore/validations/ObservationValidationsTableBody.scala b/explore/src/main/scala/explore/validations/ObservationValidationsTableBody.scala index 0a51eb31e6..8b91fe201a 100644 --- a/explore/src/main/scala/explore/validations/ObservationValidationsTableBody.scala +++ b/explore/src/main/scala/explore/validations/ObservationValidationsTableBody.scala @@ -100,7 +100,7 @@ object ObservationValidationsTableBody { row .forObsOption(row => val obs = row.obs - if (obs.hasNeedsApprovalError) + if (obs.hasNotRequestedCode) obs.configuration.flatMap(config => Button( "Request Approval", diff --git a/model/shared/src/main/scala/explore/model/Observation.scala b/model/shared/src/main/scala/explore/model/Observation.scala index eaabb4e000..201cff437f 100644 --- a/model/shared/src/main/scala/explore/model/Observation.scala +++ b/model/shared/src/main/scala/explore/model/Observation.scala @@ -271,24 +271,27 @@ case class Observation( ExecutedStates.contains(workflow.state) || workflow.validTransitions.exists(ExecutedStates.contains) - inline def configurationApplies(config: Configuration): Boolean = - configuration.fold(false)(config.subsumes) + inline def newConfigurationRequestApplies(config: Configuration): Boolean = + (hasNotRequestedCode || hasDeniedValidationCode) && + configuration.fold(false)(config.subsumes) + + inline def hasValidationCode(code: ObservationValidationCode): Boolean = + workflow.validationErrors.exists(_.code === code) // If an observation has a ConfigurationRequest* error, it is the only error they will have - inline def hasNeedsApprovalError: Boolean = - workflow.validationErrors.exists(ov => - ov.code === ObservationValidationCode.ConfigurationRequestNotRequested - ) + inline def hasNotRequestedCode: Boolean = + hasValidationCode(ObservationValidationCode.ConfigurationRequestNotRequested) - inline def updateNeedsApprovalToPending: Observation = - if (hasNeedsApprovalError) - Observation.validationErrors.replace(List(ObservationValidation.configurationRequestPending))( - this - ) - else this + inline def hasDeniedValidationCode: Boolean = + hasValidationCode(ObservationValidationCode.ConfigurationRequestDenied) + + inline def updateToPending: Observation = + Observation.validationErrors.replace(List(ObservationValidation.configurationRequestPending))( + this + ) def updateToPendingIfConfigurationApplies(config: Configuration): Observation = - if (configurationApplies(config)) updateNeedsApprovalToPending + if (newConfigurationRequestApplies(config)) updateToPending else this def asterismTracking(allTargets: TargetList): Option[ObjectTracking] = diff --git a/model/shared/src/main/scala/explore/model/ProgramSummaries.scala b/model/shared/src/main/scala/explore/model/ProgramSummaries.scala index 2af0034c2c..c8fcca48f3 100644 --- a/model/shared/src/main/scala/explore/model/ProgramSummaries.scala +++ b/model/shared/src/main/scala/explore/model/ProgramSummaries.scala @@ -4,6 +4,7 @@ package explore.model import cats.Eq +import cats.data.NonEmptyList import cats.data.NonEmptySet import cats.derived.* import cats.implicits.* @@ -11,6 +12,7 @@ import crystal.Pot import explore.data.KeyedIndexedList import explore.model.syntax.all.* import lucuma.core.enums.ScienceBand +import lucuma.core.model.Configuration import lucuma.core.model.ConfigurationRequest import lucuma.core.model.ConstraintSet import lucuma.core.model.Group @@ -124,7 +126,16 @@ case class ProgramSummaries( ) .toMap - import cats.instances.all.given + lazy val configsWithoutRequests: Map[Configuration, NonEmptyList[Observation]] = + val l = observations.toList + .filter: o => + o.configuration.fold(false): config => + o.hasNotRequestedCode || + (o.hasDeniedValidationCode && + configurationRequests.forall((_, v) => v.configuration =!= config)) + l.foldRight(Map.empty[Configuration, NonEmptyList[Observation]])((o, m) => + m.updatedWith(o.configuration.get)(_.fold(NonEmptyList.one(o))(nel => nel :+ o).some) + ) def getObsClone( originalId: Observation.Id, diff --git a/model/shared/src/main/scala/explore/model/TileIds.scala b/model/shared/src/main/scala/explore/model/TileIds.scala index b6b4f07cca..f265ce1f2e 100644 --- a/model/shared/src/main/scala/explore/model/TileIds.scala +++ b/model/shared/src/main/scala/explore/model/TileIds.scala @@ -32,12 +32,13 @@ enum ObsSummaryTabTileIds: case PlotId => "plot".refined enum ProgramTabTileIds: - case DetailsId, NotesId, ChangeRequestsId + case DetailsId, NotesId, ChangeRequestsId, UnrequestedConfigsId def id: NonEmptyString = this match - case DetailsId => "programDetails".refined - case NotesId => "programNotes".refined - case ChangeRequestsId => "programChangeRequests".refined + case DetailsId => "programDetails".refined + case NotesId => "programNotes".refined + case ChangeRequestsId => "programChangeRequests".refined + case UnrequestedConfigsId => "unrequestedConfigs".refined enum ProposalTabTileIds: case DetailsId, UsersId, AbstractId, AttachmentsId