diff --git a/.vscode/settings.json b/.vscode/settings.json index 6ab08d6859..2bf230f1e0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,5 @@ } }, "editor.formatOnSave": true, - "stylelint.validate": ["css", "scss"], - "metals.inlayHints.implicitArguments.enable": true + "stylelint.validate": ["css", "scss"] } diff --git a/explore/src/clue/scala/queries/common/AsterismQueriesGQL.scala b/explore/src/clue/scala/queries/common/AsterismQueriesGQL.scala index 61d63db994..b872882de9 100644 --- a/explore/src/clue/scala/queries/common/AsterismQueriesGQL.scala +++ b/explore/src/clue/scala/queries/common/AsterismQueriesGQL.scala @@ -20,4 +20,25 @@ object AsterismQueriesGQL { } """ } + + // Group these 2 mutations together so that the targetEdit and observationEdit subscriptions come + // close together and get grouped by the programCache so we don't have an invalid state for the UI. + @GraphQL + trait UpdateTargetsAndAsterismsMutation extends GraphQLOperation[ObservationDB] { + val document = """ + mutation($targetInput: UpdateTargetsInput!,$asterismInput: UpdateAsterismsInput!) { + updateTargets(input: $targetInput) { + targets { + id + } + } + + updateAsterisms(input: $asterismInput) { + observations { + id + } + } + } + """ + } } diff --git a/explore/src/main/scala/explore/common/AsterismQueries.scala b/explore/src/main/scala/explore/common/AsterismQueries.scala index 8c6aae7466..3d0b72708c 100644 --- a/explore/src/main/scala/explore/common/AsterismQueries.scala +++ b/explore/src/main/scala/explore/common/AsterismQueries.scala @@ -11,6 +11,7 @@ import explore.DefaultErrorPolicy import explore.model.Observation import lucuma.core.model.Target import lucuma.schemas.ObservationDB +import lucuma.schemas.ObservationDB.Enums.Existence import lucuma.schemas.ObservationDB.Types.* import lucuma.schemas.odb.input.* import queries.common.AsterismQueriesGQL.* @@ -60,3 +61,33 @@ object AsterismQueries: SET = EditAsterismsPatchInput(ADD = toAdd.assign, DELETE = toRemove.assign) ) UpdateAsterismsMutation[F].execute(input).void + + def undeleteTargetsAndAddToAsterism[F[_]: Async]( + obsIds: List[Observation.Id], + targetIds: List[Target.Id] + )(using FetchClient[F, ObservationDB]): F[Unit] = + val targetInput = UpdateTargetsInput( + WHERE = targetIds.toWhereTargets.assign, + SET = TargetPropertiesInput(existence = Existence.Present.assign), + includeDeleted = true.assign + ) + val asterismInput = UpdateAsterismsInput( + WHERE = obsIds.toWhereObservation.assign, + SET = EditAsterismsPatchInput(ADD = targetIds.assign) + ) + UpdateTargetsAndAsterismsMutation[F].execute(targetInput, asterismInput).void + + def deleteTargetsAndRemoveFromAsterism[F[_]: Async]( + obsIds: List[Observation.Id], + targetIds: List[Target.Id] + )(using FetchClient[F, ObservationDB]): F[Unit] = + val targetInput = UpdateTargetsInput( + WHERE = targetIds.toWhereTargets.assign, + SET = TargetPropertiesInput(existence = Existence.Deleted.assign), + includeDeleted = true.assign + ) + val asterismInput = UpdateAsterismsInput( + WHERE = obsIds.toWhereObservation.assign, + SET = EditAsterismsPatchInput(DELETE = targetIds.assign) + ) + UpdateTargetsAndAsterismsMutation[F].execute(targetInput, asterismInput).void diff --git a/explore/src/main/scala/explore/common/TargetQueries.scala b/explore/src/main/scala/explore/common/TargetQueries.scala index 5098d3346a..045e87e425 100644 --- a/explore/src/main/scala/explore/common/TargetQueries.scala +++ b/explore/src/main/scala/explore/common/TargetQueries.scala @@ -6,11 +6,15 @@ package explore.common import cats.effect.Sync import cats.syntax.all.given import clue.FetchClient +import clue.data.syntax.* import explore.DefaultErrorPolicy import explore.utils.* import lucuma.core.model.Program import lucuma.core.model.Target import lucuma.schemas.ObservationDB +import lucuma.schemas.ObservationDB.Enums.Existence +import lucuma.schemas.ObservationDB.Types.TargetPropertiesInput +import lucuma.schemas.ObservationDB.Types.UpdateTargetsInput import lucuma.schemas.odb.input.* import queries.common.TargetQueriesGQL @@ -27,3 +31,21 @@ object TargetQueries: .execute(target.toCreateTargetInput(programId)) .map(_.createTarget.target.id) .flatTap(id => ToastCtx[F].showToast(s"Created new target [$id]")) + + def setTargetExistence[F[_]: Sync]( + programId: Program.Id, + targetId: Target.Id, + existence: Existence + )(using FetchClient[F, ObservationDB]): F[Unit] = + TargetQueriesGQL + .UpdateTargetsMutation[F] + .execute( + UpdateTargetsInput( + WHERE = targetId.toWhereTarget + .copy(program = programId.toWhereProgram.assign) + .assign, + SET = TargetPropertiesInput(existence = existence.assign), + includeDeleted = true.assign + ) + ) + .void diff --git a/explore/src/main/scala/explore/tabs/AsterismEditorTile.scala b/explore/src/main/scala/explore/tabs/AsterismEditorTile.scala index 3c501a0375..a7a9bb57cc 100644 --- a/explore/src/main/scala/explore/tabs/AsterismEditorTile.scala +++ b/explore/src/main/scala/explore/tabs/AsterismEditorTile.scala @@ -11,12 +11,12 @@ import crystal.react.* import explore.components.Tile import explore.components.ui.ExploreStyles import explore.config.ObsTimeEditor -import explore.model.AsterismIds import explore.model.GlobalPreferences import explore.model.ObsConfiguration import explore.model.ObsIdSet import explore.model.ObsTabTilesIds import explore.model.ObservationsAndTargets +import explore.model.OnAsterismUpdateParams import explore.model.OnCloneParameters import explore.model.TargetEditObsInfo import explore.model.enums.TileSizeState @@ -42,7 +42,6 @@ object AsterismEditorTile: userId: Option[User.Id], programId: Program.Id, obsIds: ObsIdSet, - asterismIds: View[AsterismIds], obsAndTargets: UndoSetter[ObservationsAndTargets], configuration: Option[BasicConfiguration], vizTime: View[Option[Instant]], @@ -50,6 +49,7 @@ object AsterismEditorTile: currentTarget: Option[Target.Id], setTarget: (Option[Target.Id], SetRouteVia) => Callback, onCloneTarget: OnCloneParameters => Callback, + onAsterismUpdate: OnAsterismUpdateParams => Callback, obsInfo: Target.Id => TargetEditObsInfo, searching: View[Set[Target.Id]], title: String, @@ -59,6 +59,7 @@ object AsterismEditorTile: backButton: Option[VdomNode] = none )(using FetchClient[IO, ObservationDB], Logger[IO]): Tile = { // Save the time here. this works for the obs and target tabs + // It's OK to save the viz time for executed observations, I think. val vizTimeView = vizTime.withOnMod(t => ObsQueries.updateVisualizationTime[IO](obsIds.toList, t).runAsync) @@ -79,13 +80,13 @@ object AsterismEditorTile: uid, programId, obsIds, - asterismIds, obsAndTargets, vizTime, obsConf, currentTarget, setTarget, onCloneTarget, + onAsterismUpdate, obsInfo, searching, renderInTitle, diff --git a/explore/src/main/scala/explore/tabs/ObsTabTiles.scala b/explore/src/main/scala/explore/tabs/ObsTabTiles.scala index 6d957514c3..3f3be76566 100644 --- a/explore/src/main/scala/explore/tabs/ObsTabTiles.scala +++ b/explore/src/main/scala/explore/tabs/ObsTabTiles.scala @@ -28,6 +28,7 @@ import explore.model.LoadingState import explore.model.Observation import explore.model.Observation.observingMode import explore.model.ObservationsAndTargets +import explore.model.OnAsterismUpdateParams import explore.model.OnCloneParameters import explore.model.ProgramSummaries import explore.model.TargetEditObsInfo @@ -477,12 +478,16 @@ object ObsTabTiles: def onCloneTarget(params: OnCloneParameters): Callback = setCurrentTarget(params.idToAdd.some, SetRouteVia.HistoryReplace) + def onAsterismUpdate(params: OnAsterismUpdateParams): Callback = + val targetForPage: Option[Target.Id] = + if (params.areAddingTarget) params.targetId.some else none + setCurrentTarget(targetForPage, SetRouteVia.HistoryReplace) + val targetTile: Tile = AsterismEditorTile.asterismEditorTile( props.vault.userId, props.programId, ObsIdSet.one(props.obsId), - asterismIds, props.obsAndTargets, basicConfiguration, vizTimeView, @@ -490,6 +495,7 @@ object ObsTabTiles: props.focusedTarget, setCurrentTarget, onCloneTarget, + onAsterismUpdate, getObsInfo(props.obsId), props.searching, "Targets", diff --git a/explore/src/main/scala/explore/tabs/TargetTabContents.scala b/explore/src/main/scala/explore/tabs/TargetTabContents.scala index d1d0ed9a76..9b8df9dac5 100644 --- a/explore/src/main/scala/explore/tabs/TargetTabContents.scala +++ b/explore/src/main/scala/explore/tabs/TargetTabContents.scala @@ -54,7 +54,6 @@ import lucuma.ui.optics.* import lucuma.ui.reusability.given import lucuma.ui.syntax.all.given import monocle.Iso -import monocle.Traversal import queries.schemas.odb.ObsQueries import java.time.Instant @@ -128,27 +127,6 @@ object TargetTabContents extends TwoPanels: setPage(Focused.None) )(targetId => setPage(Focused.target(targetId))) - def onModAsterismsWithObs( - groupIds: ObsIdSet, - editedIds: ObsIdSet - )(ps: ProgramSummaries): Callback = - findAsterismGroup(editedIds, ps.asterismGroups).foldMap { tlg => - // We should always find the group. - // If a group was edited while closed and it didn't create a merger, keep it closed, - // otherwise expand all affected groups. - props.expandedIds - .mod { eids => - val withOld = - if (groupIds === editedIds) eids - else eids + groupIds.removeUnsafe(editedIds) - val withOldAndNew = - if (editedIds === tlg.obsIds && editedIds === groupIds) withOld - else withOld + tlg.obsIds - - withOldAndNew.filter(ids => ps.asterismGroups.contains(ids)) // clean up - } - } - val backButton: VdomNode = makeBackButton(props.programId, AppTab.Targets, selectedView, ctx) @@ -191,8 +169,6 @@ object TargetTabContents extends TwoPanels: idsToEdit: ObsIdSet, asterismGroup: AsterismGroup ): List[Tile] = { - val groupIds = asterismGroup.obsIds - val getVizTime: ProgramSummaries => Option[Instant] = a => for id <- idsToEdit.single @@ -213,20 +189,6 @@ object TargetTabContents extends TwoPanels: ) .getOrElse(ps) - val traversal: Traversal[ObservationList, AsterismIds] = - Iso - .id[ObservationList] - .filterIndex((id: Observation.Id) => idsToEdit.contains(id)) - .andThen(KeyedIndexedList.value) - .andThen(Observation.scienceTargetIds) - - val asterismView: View[AsterismIds] = - CloneListView( - props.programSummaries.model - .withOnMod(onModAsterismsWithObs(groupIds, idsToEdit)) - .zoom(ProgramSummaries.observations.andThen(traversal)) - ) - val vizTimeView: View[Option[Instant]] = props.programSummaries.model.zoom(getVizTime)(modVizTime) @@ -276,7 +238,6 @@ object TargetTabContents extends TwoPanels: def onCloneTarget4Asterism(params: OnCloneParameters): Callback = // props.programSummaries.get will always contain the original groups. On creating, - // params.summaries would contain the groups after the clone, but that isn't as useful here. val allOriginalGroups = props.programSummaries.get.asterismGroups .filterForObsInSet(params.obsIds) .map(_._1) @@ -307,12 +268,41 @@ object TargetTabContents extends TwoPanels: setCurrentTarget(idsToEdit.some)(params.originalId.some, SetRouteVia.HistoryReplace) }) + def onAsterismUpdate(params: OnAsterismUpdateParams): Callback = + val originalGroups = props.programSummaries.get.asterismGroups + // props.programSummaries.get will always contain the original groups, so we should find the group + originalGroups + .findContainingObsIds(params.obsIds) + .foldMap(group => + val newAsterism = + if (params.isAddAction) group.targetIds + params.targetId + else group.targetIds - params.targetId + val existingGroup = originalGroups.findWithTargetIds(newAsterism) + val mergedObs = existingGroup.map(_.obsIds ++ params.obsIds) + val obsIdsAfterAction = mergedObs.getOrElse(params.obsIds) + val unmodified = group.obsIds -- params.obsIds + val setExpanded = unmodified.fold { + if (params.isUndo) props.expandedIds.mod(_ - obsIdsAfterAction + group.obsIds) + else props.expandedIds.mod(_ - group.obsIds + obsIdsAfterAction) + }(unmod => + if (params.isUndo) props.expandedIds.mod(_ - obsIdsAfterAction - unmod + group.obsIds) + else props.expandedIds.mod(_ - group.obsIds + obsIdsAfterAction + unmod) + ) + val targetForPage: Option[Target.Id] = + if (params.areAddingTarget) params.targetId.some + else none // if we're deleting, let UI focus the first one in the asterism + val setPage = + if (params.isUndo) + setCurrentTarget(idsToEdit.some)(targetForPage, SetRouteVia.HistoryReplace) + else setCurrentTarget(params.obsIds.some)(targetForPage, SetRouteVia.HistoryReplace) + setExpanded >> setPage + ) + val asterismEditorTile = AsterismEditorTile.asterismEditorTile( props.userId, props.programId, idsToEdit, - asterismView, props.obsAndTargets, configuration, vizTimeView, @@ -320,6 +310,7 @@ object TargetTabContents extends TwoPanels: props.focused.target, setCurrentTarget(idsToEdit.some), onCloneTarget4Asterism, + onAsterismUpdate, getObsInfo(idsToEdit.some), props.searching, title, diff --git a/explore/src/main/scala/explore/targeteditor/AsterismActions.scala b/explore/src/main/scala/explore/targeteditor/AsterismActions.scala new file mode 100644 index 0000000000..f5dea8e5d2 --- /dev/null +++ b/explore/src/main/scala/explore/targeteditor/AsterismActions.scala @@ -0,0 +1,108 @@ +// 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.targeteditor + +import cats.effect.IO +import clue.FetchClient +import crystal.react.syntax.all.* +import explore.common.AsterismQueries +import explore.model.ObsIdSet +import explore.model.ObservationsAndTargets +import explore.model.OnAsterismUpdateParams +import explore.model.syntax.all.* +import explore.undo.* +import japgolly.scalajs.react.* +import lucuma.core.model.Target +import lucuma.schemas.ObservationDB +import lucuma.schemas.model.TargetWithId + +import scala.annotation.unused + +object AsterismActions: + extension (obsAndTargets: ObservationsAndTargets) + private def asterismHasTarget(targetId: Target.Id, obsIds: ObsIdSet): Boolean = + // since we're dealing with asterisms, if the target is in one observation in obsIds, it should be in all + obsAndTargets._1.getValue(obsIds.head).fold(false)(_.scienceTargetIds.contains(targetId)) + // If the target was created, but has been assigned to another observation (unlikely), perhaps by another + // user or in another session, then we won't delete it + private def shouldDelete(targetId: Target.Id, obsIds: ObsIdSet, createdTarget: Boolean) = + createdTarget && !obsAndTargets._1.isTargetInOtherObs(targetId, obsIds) + + // returns True if the observations contain the target + private def getter(targetId: Target.Id, obsIds: ObsIdSet): ObservationsAndTargets => Boolean = + _.asterismHasTarget(targetId, obsIds) + + private def setter(target: TargetWithId, obsIds: ObsIdSet, createdTarget: Boolean)( + @unused unused: Boolean + ): ObservationsAndTargets => ObservationsAndTargets = obsAndTargets => + if (obsAndTargets.asterismHasTarget(target.id, obsIds)) + // we're removing the target from the asterisms + val obs = obsAndTargets._1.removeTargetFromObservations(target.id, obsIds) + val targets = + if (obsAndTargets.shouldDelete(target.id, obsIds, createdTarget)) + obsAndTargets._2 - target.id + else obsAndTargets._2 + (obs, targets) + else + val obs = obsAndTargets._1.addTargetToObservations(target.id, obsIds) + val targets = + if (createdTarget) obsAndTargets._2 + (target.id -> target.target) + else obsAndTargets._2 + (obs, targets) + + private def updateRemote( + targetId: Target.Id, + obsIds: ObsIdSet, + obsAndTargets: ObservationsAndTargets, // this is the value before the setter + createdTarget: Boolean + )(using + FetchClient[IO, ObservationDB] + ): IO[Unit] = + val asterismHasTarget = obsAndTargets.asterismHasTarget(targetId, obsIds) + val targetList = List(targetId) + val obsList = obsIds.toList + + // if we change the existence, we group the target and asterism updates + if (asterismHasTarget) + // Removing it + if (obsAndTargets.shouldDelete(targetId, obsIds, createdTarget)) + AsterismQueries.deleteTargetsAndRemoveFromAsterism(obsList, targetList) + else AsterismQueries.removeTargetsFromAsterisms(obsList, targetList) + else if (createdTarget) + AsterismQueries.undeleteTargetsAndAddToAsterism(obsList, targetList) + else AsterismQueries.addTargetsToAsterisms(obsList, targetList) + + def addTargetToAsterisms( + target: TargetWithId, + obsIds: ObsIdSet, + createdTarget: Boolean, + onAsterismUpdate: OnAsterismUpdateParams => Callback + )(using + FetchClient[IO, ObservationDB] + ): Action[ObservationsAndTargets, Boolean] = + Action(getter(target.id, obsIds), setter(target, obsIds, createdTarget))( + onSet = (obsAndTargets, _) => updateRemote(target.id, obsIds, obsAndTargets, createdTarget), + onRestore = + (obsAndTargets, hasTarget) => // the pre-setter obsAndTargets and postSetter hasTarget + onAsterismUpdate(OnAsterismUpdateParams(target.id, obsIds, true, hasTarget)).toAsync >> + updateRemote(target.id, obsIds, obsAndTargets, createdTarget) + ) + + def removeTargetFromAsterisms( + target: TargetWithId, + obsIds: ObsIdSet, + onAsterismUpdate: OnAsterismUpdateParams => Callback + )(using + FetchClient[IO, ObservationDB] + ): Action[ObservationsAndTargets, Boolean] = + Action(getter(target.id, obsIds), setter(target, obsIds, false))( + onSet = ( + obsAndTargets, + _ + ) => updateRemote(target.id, obsIds, obsAndTargets, false), + onRestore = + (obsAndTargets, hasTarget) => // the pre-setter obsAndTargets and postSetter hasTarget + onAsterismUpdate(OnAsterismUpdateParams(target.id, obsIds, false, hasTarget)).toAsync >> + updateRemote(target.id, obsIds, obsAndTargets, false) + ) diff --git a/explore/src/main/scala/explore/targeteditor/AsterismEditor.scala b/explore/src/main/scala/explore/targeteditor/AsterismEditor.scala index 653e65b38d..c9822c7808 100644 --- a/explore/src/main/scala/explore/targeteditor/AsterismEditor.scala +++ b/explore/src/main/scala/explore/targeteditor/AsterismEditor.scala @@ -13,14 +13,16 @@ import explore.config.ObsTimeEditor import explore.model.AladinFullScreen import explore.model.AppContext import explore.model.Asterism -import explore.model.AsterismIds import explore.model.GlobalPreferences import explore.model.ObsConfiguration import explore.model.ObsIdSet +import explore.model.ObsIdSetEditInfo import explore.model.ObservationsAndTargets +import explore.model.OnAsterismUpdateParams import explore.model.OnCloneParameters import explore.model.TargetEditObsInfo import explore.model.TargetList +import explore.model.reusability.given import explore.undo.UndoSetter import japgolly.scalajs.react.* import japgolly.scalajs.react.extra.router.SetRouteVia @@ -41,13 +43,13 @@ case class AsterismEditor( userId: User.Id, programId: Program.Id, obsIds: ObsIdSet, - asterismIds: View[AsterismIds], obsAndTargets: UndoSetter[ObservationsAndTargets], vizTime: View[Option[Instant]], configuration: ObsConfiguration, focusedTargetId: Option[Target.Id], setTarget: (Option[Target.Id], SetRouteVia) => Callback, onCloneTarget: OnCloneParameters => Callback, + onAsterismUpdate: OnAsterismUpdateParams => Callback, obsInfo: Target.Id => TargetEditObsInfo, searching: View[Set[Target.Id]], renderInTitle: Tile.RenderInTitle, @@ -68,20 +70,24 @@ object AsterismEditor extends AsterismModifier: .withHooks[Props] .useContext(AppContext.ctx) .useStateView(AreAdding(false)) - .useEffectWithDepsBy((props, _, _) => (props.asterismIds.get, props.focusedTargetId)) { - (props, _, _) => (asterismIds, focusedTargetId) => - // If the selected targetId is None, or not in the asterism, select the first target (if any). - // Need to replace history here. - focusedTargetId.filter(asterismIds.contains_) match - case None => props.setTarget(asterismIds.headOption, SetRouteVia.HistoryReplace) - case _ => Callback.empty + .useMemoBy((props, _, _) => (props.obsIds, props.obsAndTargets.get._1)) { (_, _, _) => + ObsIdSetEditInfo.fromObservationList + } + .useLayoutEffectWithDepsBy((props, _, _, obsEditInfo) => + (obsEditInfo.asterismIds, props.focusedTargetId) + ) { (props, _, _, _) => (asterismIds, focusedTargetId) => + // If the selected targetId is None, or not in the asterism, select the first target (if any). + // Need to replace history here. + focusedTargetId.filter(asterismIds.contains_) match + case None => props.setTarget(asterismIds.headOption, SetRouteVia.HistoryReplace) + case _ => Callback.empty } // full screen aladin .useStateView(AladinFullScreen.Normal) - .render { (props, ctx, adding, fullScreen) => + .render { (props, ctx, adding, obsEditInfo, fullScreen) => import ctx.given - // Save the time here. this works for the obs and target tabs + // It's OK to set the viz time for executed observations, I think. val vizTimeView = props.vizTime.withOnMod(t => ObsQueries .updateVisualizationTime[IO](props.obsIds.toList, t) @@ -99,37 +105,51 @@ object AsterismEditor extends AsterismModifier: props.setTarget(newValue, SetRouteVia.HistoryPush) >> cb(oldValue, newValue) ) + // the 'getOrElse doesn't matter. Controls will be readonly if all are executed + val unexecutedObs = obsEditInfo.unExecuted.getOrElse(props.obsIds) + + val editWarningMsg: Option[String] = + if (obsEditInfo.allAreExecuted) + if (obsEditInfo.editing.length > 1) + "All of the current observations are executed. Asterism is readonly.".some + else "The current observation has been executed. Asterism is readonly.".some + else if (obsEditInfo.executed.isDefined) + "Adding and removing targets will only affect the unexecuted observations.".some + else none + <.div( ExploreStyles.AladinFullScreen.when(fullScreen.get.value), + // only pass in the unexecuted observations. Will be readonly if there aren't any props.renderInTitle( targetSelectionPopup( "Add", props.programId, - props.obsIds, - props.asterismIds, - props.allTargets.model, + unexecutedObs, + props.obsAndTargets, adding, - selectedTargetView.async.set, - props.readonly, + props.onAsterismUpdate, + props.readonly || obsEditInfo.allAreExecuted, ExploreStyles.AddTargetButton ) ), props.renderInTitle(ObsTimeEditor(vizTimeView)), + editWarningMsg.map(msg => <.div(ExploreStyles.SharedEditWarning, msg)), TargetTable( props.userId.some, props.programId, - props.obsIds, - props.asterismIds, - props.allTargets.model, + unexecutedObs, + obsEditInfo.asterismIds, + props.obsAndTargets, selectedTargetView, + props.onAsterismUpdate, vizTime, props.renderInTitle, fullScreen.get, - props.readonly + props.readonly || obsEditInfo.allAreExecuted ), // it's possible for us to get here without an asterism but with a focused target id. This will get // corrected, but we need to not render the target editor before it is corrected. - (Asterism.fromIdsAndTargets(props.asterismIds.get, props.allTargets.get), + (Asterism.fromIdsAndTargets(obsEditInfo.asterismIds, props.allTargets.get), props.focusedTargetId ).mapN { (asterism, focusedTargetId) => val selectedTargetOpt: Option[UndoSetter[Target.Sidereal]] = diff --git a/explore/src/main/scala/explore/targeteditor/AsterismModifier.scala b/explore/src/main/scala/explore/targeteditor/AsterismModifier.scala index 7b95d89e52..01facc2a6f 100644 --- a/explore/src/main/scala/explore/targeteditor/AsterismModifier.scala +++ b/explore/src/main/scala/explore/targeteditor/AsterismModifier.scala @@ -8,66 +8,71 @@ import cats.syntax.all.* import clue.FetchClient import crystal.react.* import explore.Icons -import explore.common.AsterismQueries import explore.common.TargetQueries import explore.components.ui.ExploreStyles -import explore.model.AsterismIds import explore.model.ObsIdSet -import explore.model.TargetList +import explore.model.ObservationsAndTargets +import explore.model.OnAsterismUpdateParams import explore.syntax.ui.* import explore.targets.TargetSelectionPopup import explore.targets.TargetSource +import explore.undo.UndoSetter import explore.utils.ToastCtx +import japgolly.scalajs.react.* import lucuma.core.model.Program import lucuma.core.model.Target import lucuma.react.common.Css import lucuma.react.primereact.Button import lucuma.schemas.ObservationDB +import lucuma.schemas.model.TargetWithId import lucuma.schemas.model.TargetWithOptId import lucuma.ui.primereact.* import org.typelevel.log4cats.Logger trait AsterismModifier: - // In the future, we could unify all this into an operation over ProgramSummaries, - // and add undo. - // We have to be careful with undo, though. The inserted target could be in use in - // another asterism by the time we undo its creation. What happens then? - // Can the DB handle this (ie: keep the target if it's in use)? - // Does the DB remove it from all asterisms? - // If we keep it, what happens if we redo? protected def insertSiderealTarget( - programId: Program.Id, - obsIds: ObsIdSet, - asterismIds: View[AsterismIds], - allTargets: View[TargetList], - targetWithOptId: TargetWithOptId - )(using FetchClient[IO, ObservationDB], Logger[IO], ToastCtx[IO]): IO[Option[Target.Id]] = + programId: Program.Id, + obsIds: ObsIdSet, + obsAndTargets: UndoSetter[ObservationsAndTargets], + targetWithOptId: TargetWithOptId, + onAsterismUpdate: OnAsterismUpdateParams => Callback + )(using FetchClient[IO, ObservationDB], Logger[IO], ToastCtx[IO]): IO[Unit] = targetWithOptId match case TargetWithOptId(oTargetId, target @ Target.Sidereal(_, _, _, _)) => - val targetId: IO[Target.Id] = oTargetId.fold( - TargetQueries - .insertTarget[IO](programId, target) - .flatTap(id => allTargets.async.mod(_ + (id -> target))) - )(IO(_)) - - targetId - .flatTap(tid => AsterismQueries.addTargetsToAsterisms[IO](obsIds.toList, List(tid))) - .flatTap(tid => asterismIds.async.mod(_ + tid)) - .map(_.some) + oTargetId + .fold( + TargetQueries + .insertTarget[IO](programId, target) + .map((_, true)) + )(id => IO((id, false))) + .flatMap((id, created) => + (AsterismActions + .addTargetToAsterisms( + TargetWithId(id, targetWithOptId.target), + obsIds, + created, + onAsterismUpdate + ) + .set(obsAndTargets)(false) >> + // Do the first onAsterismUpdate here so it is synchronous with the setter in the Action. + // the ".async.toCallback" seems to let the model update before we try changing the UI + onAsterismUpdate( + OnAsterismUpdateParams(id, obsIds, true, true) + ).async.toCallback).toAsync + ) case _ => - IO(none) + IO.unit def targetSelectionPopup( - label: String, - programId: Program.Id, - obsIds: ObsIdSet, - targetIds: View[AsterismIds], - targetInfo: View[TargetList], - adding: View[AreAdding], - after: Option[Target.Id] => IO[Unit] = _ => IO.unit, - readOnly: Boolean = false, - buttonClass: Css = Css.Empty + label: String, + programId: Program.Id, + obsIds: ObsIdSet, + obsAndTargets: UndoSetter[ObservationsAndTargets], + adding: View[AreAdding], + onAsterismUpdate: OnAsterismUpdateParams => Callback, + readOnly: Boolean = false, + buttonClass: Css = Css.Empty )(using FetchClient[IO, ObservationDB], Logger[IO], @@ -92,10 +97,8 @@ trait AsterismModifier: insertSiderealTarget( programId, obsIds, - targetIds, - targetInfo, - targetWithOptId - ).flatMap(after) - .switching(adding.async, AreAdding(_)) - .runAsync + obsAndTargets, + targetWithOptId, + onAsterismUpdate + ).switching(adding.async, AreAdding(_)).runAsync ) diff --git a/explore/src/main/scala/explore/targeteditor/TargetCloneAction.scala b/explore/src/main/scala/explore/targeteditor/TargetCloneAction.scala index 057fb41fb6..a2380b79cb 100644 --- a/explore/src/main/scala/explore/targeteditor/TargetCloneAction.scala +++ b/explore/src/main/scala/explore/targeteditor/TargetCloneAction.scala @@ -6,40 +6,26 @@ package explore.targeteditor import cats.effect.IO import cats.syntax.all.* import clue.FetchClient -import clue.data.syntax.* import crystal.react.syntax.all.* -import explore.DefaultErrorPolicy import explore.common.AsterismQueries +import explore.common.TargetQueries import explore.model.ObsIdSet import explore.model.Observation import explore.model.ObservationList import explore.model.ObservationsAndTargets import explore.model.OnCloneParameters +import explore.model.syntax.all.* import explore.undo.* import japgolly.scalajs.react.* import lucuma.core.model.Program import lucuma.core.model.Target import lucuma.schemas.ObservationDB import lucuma.schemas.ObservationDB.Enums.Existence -import lucuma.schemas.ObservationDB.Types.* import lucuma.schemas.model.TargetWithId -import lucuma.schemas.odb.input.* -import queries.common.TargetQueriesGQL import scala.annotation.unused object TargetCloneAction { - extension (observations: ObservationList) - private def allWithTarget(targetId: Target.Id): Set[Observation.Id] = - observations.values - .filter(_.scienceTargetIds.contains(targetId)) - .map(_.id) - .toSet - // determine if the observation has been assigned to additional observations since the cloning. - // If it has been assigned to other observations, we won't delete it locally or remotely. - private def areExtraObs(targetId: Target.Id, obsIds: ObsIdSet): Boolean = - (allWithTarget(targetId) -- obsIds.idSet.toSortedSet).nonEmpty - extension (obsAndTargets: ObservationsAndTargets) private def cloneTargetForObservations( originalId: Target.Id, @@ -59,8 +45,10 @@ object TargetCloneAction { val obs = obsIds.idSet.foldLeft(obsAndTargets._1)((list, obsId) => list.updatedValueWith(obsId, Observation.scienceTargetIds.modify(_ + originalId - cloneId)) ) - val ts = - if (obsAndTargets._1.areExtraObs(cloneId, obsIds)) + val ts = + // determine if the observation has been assigned to additional observations since the cloning. + // If it has been assigned to other observations, we won't delete it locally or remotely. + if (obsAndTargets._1.isTargetInOtherObs(cloneId, obsIds)) obsAndTargets._2 else obsAndTargets._2 - cloneId @@ -91,24 +79,13 @@ object TargetCloneAction { else { // If the clone has been assigned to another observation (unlikely), perhaps by another // user or in another session , then we won't delete it - if (observations.areExtraObs(onCloneParms.cloneId, onCloneParms.obsIds)) + if (observations.isTargetInOtherObs(onCloneParms.cloneId, onCloneParms.obsIds)) none else Existence.Deleted.some } optExistence.foldMap(existence => - TargetQueriesGQL - .UpdateTargetsMutation[IO] - .execute( - UpdateTargetsInput( - WHERE = onCloneParms.cloneId.toWhereTarget - .copy(program = programId.toWhereProgram.assign) - .assign, - SET = TargetPropertiesInput(existence = existence.assign), - includeDeleted = true.assign - ) - ) - .void + TargetQueries.setTargetExistence[IO](programId, onCloneParms.cloneId, existence) ) >> AsterismQueries.addAndRemoveTargetsFromAsterisms(onCloneParms.obsIds.toList, toAdd = List(onCloneParms.idToAdd), diff --git a/explore/src/main/scala/explore/targeteditor/TargetTable.scala b/explore/src/main/scala/explore/targeteditor/TargetTable.scala index 91b771c52c..f972acbf34 100644 --- a/explore/src/main/scala/explore/targeteditor/TargetTable.scala +++ b/explore/src/main/scala/explore/targeteditor/TargetTable.scala @@ -11,7 +11,6 @@ import crystal.react.* import crystal.react.given import crystal.react.hooks.* import explore.Icons -import explore.common.AsterismQueries import explore.common.UserPreferencesQueries import explore.common.UserPreferencesQueries.TableStore import explore.components.Tile @@ -20,10 +19,12 @@ import explore.model.AladinFullScreen import explore.model.AppContext import explore.model.AsterismIds import explore.model.ObsIdSet -import explore.model.TargetList +import explore.model.ObservationsAndTargets +import explore.model.OnAsterismUpdateParams import explore.model.enums.TableId import explore.model.extensions.* import explore.targets.TargetColumns +import explore.undo.UndoSetter import japgolly.scalajs.react.* import japgolly.scalajs.react.vdom.html_<^.* import lucuma.core.model.Program @@ -36,6 +37,7 @@ import lucuma.react.syntax.* import lucuma.react.table.* import lucuma.schemas.ObservationDB import lucuma.schemas.model.SiderealTargetWithId +import lucuma.schemas.model.TargetWithId import lucuma.ui.primereact.* import lucuma.ui.reusability.given import lucuma.ui.syntax.* @@ -46,23 +48,30 @@ import lucuma.ui.table.hooks.* import java.time.Instant case class TargetTable( - userId: Option[User.Id], - programId: Program.Id, - obsIds: ObsIdSet, // Only used to invoke DB + userId: Option[User.Id], + programId: Program.Id, + obsIds: ObsIdSet, // Only used to invoke DB - should only be unexecuted observations // Targets are not modified here, we only modify which ones belong to the Asterism. - targetIds: View[AsterismIds], - targetInfo: View[TargetList], - selectedTarget: View[Option[Target.Id]], - vizTime: Option[Instant], - renderInTitle: Tile.RenderInTitle, - fullScreen: AladinFullScreen, - readOnly: Boolean + targetIds: AsterismIds, + obsAndTargets: UndoSetter[ObservationsAndTargets], + selectedTarget: View[Option[Target.Id]], + onAsterismUpdate: OnAsterismUpdateParams => Callback, + vizTime: Option[Instant], + renderInTitle: Tile.RenderInTitle, + fullScreen: AladinFullScreen, + readOnly: Boolean ) extends ReactFnProps(TargetTable.component) object TargetTable extends AsterismModifier: private type Props = TargetTable - private val ColDef = ColumnDef[SiderealTargetWithId] + private case class TableMeta( + obsIds: ObsIdSet, + obsAndTargets: UndoSetter[ObservationsAndTargets], + onAsterismUpdate: OnAsterismUpdateParams => Callback + ) + + private val ColDef = ColumnDef.WithTableMeta[SiderealTargetWithId, TableMeta] private val DeleteColumnId: ColumnId = ColumnId("delete") @@ -77,16 +86,22 @@ object TargetTable extends AsterismModifier: ) private def deleteSiderealTarget( - obsIds: ObsIdSet, - targetId: Target.Id - )(using FetchClient[IO, ObservationDB]): IO[Unit] = - AsterismQueries.removeTargetsFromAsterisms[IO](obsIds.toList, List(targetId)) + obsIds: ObsIdSet, + obsAndTargets: UndoSetter[ObservationsAndTargets], + target: TargetWithId, + onAsterismUpdate: OnAsterismUpdateParams => Callback + )(using FetchClient[IO, ObservationDB]): Callback = + AsterismActions + .removeTargetFromAsterisms(target, obsIds, onAsterismUpdate) + .set(obsAndTargets)(true) >> + // the ".async.toCallback" seems to let the model update before we try changing the UI + onAsterismUpdate(OnAsterismUpdateParams(target.id, obsIds, false, false)).async.toCallback protected val component = ScalaFnComponent .withHooks[Props] .useContext(AppContext.ctx) - .useMemoBy((props, _) => props.readOnly): (props, ctx) => // cols + .useMemoBy((props, _) => props.readOnly): (_, ctx) => // cols readOnly => import ctx.given @@ -105,8 +120,14 @@ object TargetTable extends AsterismModifier: onClickE = (e: ReactMouseEvent) => e.preventDefaultCB >> e.stopPropagationCB >> - props.targetIds.mod(_ - cell.value) >> - deleteSiderealTarget(props.obsIds, cell.value).runAsync + cell.table.options.meta.foldMap(m => + deleteSiderealTarget( + m.obsIds, + m.obsAndTargets, + cell.row.original.toTargetWithId, + m.onAsterismUpdate + ) + ) ).tiny.compact, size = 35.toPx, enableSorting = false @@ -117,7 +138,7 @@ object TargetTable extends AsterismModifier: // If vizTime is not set, change it to now .useEffectKeepResultWithDepsBy((p, _, _) => p.vizTime): (_, _, _) => vizTime => IO(vizTime.getOrElse(Instant.now())) - .useMemoBy((props, _, _, vizTime) => (props.targetIds.get, props.targetInfo.get, vizTime)): // rows + .useMemoBy((props, _, _, vizTime) => (props.targetIds, props.obsAndTargets.get._2, vizTime)): // rows (_, _, _, _) => case (targetIds, targetInfo, Pot.Ready(vizTime)) => targetIds.toList @@ -140,7 +161,8 @@ object TargetTable extends AsterismModifier: enableSorting = true, enableColumnResizing = true, columnResizeMode = ColumnResizeMode.OnChange, - initialState = TableState(columnVisibility = TargetColumns.DefaultVisibility) + initialState = TableState(columnVisibility = TargetColumns.DefaultVisibility), + meta = TableMeta(props.obsIds, props.obsAndTargets, props.onAsterismUpdate) ), TableStore(props.userId, TableId.AsterismTargets, cols) ) @@ -157,13 +179,13 @@ object TargetTable extends AsterismModifier: ), if (rows.isEmpty) { <.div(ExploreStyles.HVCenter)( - AsterismEditor.targetSelectionPopup( + targetSelectionPopup( "Add a target", props.programId, props.obsIds, - props.targetIds, - props.targetInfo, + props.obsAndTargets, adding, + props.onAsterismUpdate, buttonClass = LucumaPrimeStyles.Massive ) ) diff --git a/explore/src/main/scala/explore/targets/TargetColumns.scala b/explore/src/main/scala/explore/targets/TargetColumns.scala index 90d77088eb..d061cc596c 100644 --- a/explore/src/main/scala/explore/targets/TargetColumns.scala +++ b/explore/src/main/scala/explore/targets/TargetColumns.scala @@ -100,11 +100,11 @@ object TargetColumns: ) object Builder: - trait Common[D](colDef: ColumnDef.Applied.NoMeta[D], getTarget: D => Option[Target]): + trait Common[D, TM](colDef: ColumnDef.Applied[D, TM], getTarget: D => Option[Target]): def baseColumn[V]( id: ColumnId, accessor: Target => V - ): ColumnDef.Single.NoMeta[D, Option[V]] = + ): ColumnDef.Single.WithTableMeta[D, Option[V], TM] = colDef(id, getTarget.andThen(_.map(accessor)), BaseColNames(id)) val NameColumn = @@ -141,20 +141,20 @@ object TargetColumns: .sortableBy(_.flatten.map(_.toString)) ) - trait CommonSidereal[D]( - colDef: ColumnDef.Applied.NoMeta[D], + trait CommonSidereal[D, TM]( + colDef: ColumnDef.Applied[D, TM], getSiderealTarget: D => Option[Target.Sidereal] ): def siderealColumnOpt[V]( id: ColumnId, accessor: Target.Sidereal => Option[V] - ): ColumnDef.Single.NoMeta[D, Option[V]] = + ): ColumnDef.Single.WithTableMeta[D, Option[V], TM] = colDef(id, getSiderealTarget.andThen(_.flatMap(accessor)), SiderealColNames(id)) def siderealColumn[V]( id: ColumnId, accessor: Target.Sidereal => V - ): ColumnDef.Single.NoMeta[D, Option[V]] = + ): ColumnDef.Single.WithTableMeta[D, Option[V], TM] = siderealColumnOpt(id, accessor.andThen(_.some)) /** Display measure without the uncertainty */ @@ -223,8 +223,8 @@ object TargetColumns: .sortable ) - case class ForProgram[D]( - colDef: ColumnDef.Applied.NoMeta[D], + case class ForProgram[D, TM]( + colDef: ColumnDef.Applied[D, TM], getTarget: D => Option[Target] ) extends Common(colDef, getTarget) with CommonSidereal( diff --git a/explore/src/main/scala/explore/targets/TargetSelectionPopup.scala b/explore/src/main/scala/explore/targets/TargetSelectionPopup.scala index 20bad39029..7046535cdb 100644 --- a/explore/src/main/scala/explore/targets/TargetSelectionPopup.scala +++ b/explore/src/main/scala/explore/targets/TargetSelectionPopup.scala @@ -310,7 +310,7 @@ object TargetSelectionPopup: else s"Add a new target from ${source.name} (${showCount(sourceResults.length, "result")})" - React.Fragment( + React.Fragment.withKey(source.name)( <.div(ExploreStyles.SmallHeader, header), <.div(ExploreStyles.TargetSearchResults)( TargetSelectionTable( diff --git a/model-tests/shared/src/test/scala/explore/model/TargetEditCloneInfoSuite.scala b/model-tests/shared/src/test/scala/explore/model/TargetEditCloneInfoSuite.scala index aa006baaef..3a0ab0788a 100644 --- a/model-tests/shared/src/test/scala/explore/model/TargetEditCloneInfoSuite.scala +++ b/model-tests/shared/src/test/scala/explore/model/TargetEditCloneInfoSuite.scala @@ -288,7 +288,7 @@ class TargetEditCloneInfoSuite extends FunSuite with MyAssertions { test("all of current are executed") { val obsInfo = TargetEditObsInfo(two.some, oneTwo.some, two.some) val cloneInfo = TargetEditCloneInfo.fromObsInfo(obsInfo) - assertReadonly(cloneInfo, TargetEditCloneInfo.allCurrentExecutedMsg) + assertReadonly(cloneInfo, TargetEditCloneInfo.thisExecutedMsg) } test("all of current are executed 2") { diff --git a/model/shared/src/main/scala/explore/model/ObsIdSet.scala b/model/shared/src/main/scala/explore/model/ObsIdSet.scala index ffb74fc5b8..574ebb8213 100644 --- a/model/shared/src/main/scala/explore/model/ObsIdSet.scala +++ b/model/shared/src/main/scala/explore/model/ObsIdSet.scala @@ -23,6 +23,7 @@ case class ObsIdSet(idSet: NonEmptySet[Observation.Id]): --(other.idSet.toSortedSet) def -(other: Observation.Id): Option[ObsIdSet] = NonEmptySet.fromSet(idSet - other).map(ObsIdSet(_)) + def head: Observation.Id = idSet.head object ObsIdSet { given Order[ObsIdSet] = Order.by(_.idSet) diff --git a/model/shared/src/main/scala/explore/model/ObsIdSetEditInfo.scala b/model/shared/src/main/scala/explore/model/ObsIdSetEditInfo.scala new file mode 100644 index 0000000000..2325e204e2 --- /dev/null +++ b/model/shared/src/main/scala/explore/model/ObsIdSetEditInfo.scala @@ -0,0 +1,25 @@ +// 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.model + +import cats.Eq +import cats.derived.* +import cats.syntax.all.* +import explore.model.syntax.all.* + +case class ObsIdSetEditInfo( + editing: ObsIdSet, + executed: Option[ObsIdSet], + asterismIds: AsterismIds +) derives Eq: + val unExecuted: Option[ObsIdSet] = executed.fold(editing.some)(editing.remove) + val allAreExecuted: Boolean = unExecuted.isEmpty + +object ObsIdSetEditInfo: + def fromObservationList(editing: ObsIdSet, observations: ObservationList): ObsIdSetEditInfo = + val executed = observations.executedOf(editing) + // These are all ids with the same asterism + val asterismIds = + observations.getValue(editing.head).fold(AsterismIds.empty)(_.scienceTargetIds) + ObsIdSetEditInfo(editing, executed, asterismIds) diff --git a/model/shared/src/main/scala/explore/model/Observation.scala b/model/shared/src/main/scala/explore/model/Observation.scala index c7f9c21acf..6cb06c6f49 100644 --- a/model/shared/src/main/scala/explore/model/Observation.scala +++ b/model/shared/src/main/scala/explore/model/Observation.scala @@ -167,6 +167,7 @@ case class Observation( s"${constraints.imageQuality.label} ${constraints.cloudExtinction.label} ${constraints.skyBackground.label} ${constraints.waterVapor.label}" inline def isCalibration: Boolean = calibrationRole.isDefined + inline def isExecuted: Boolean = status >= ObsStatus.Ongoing object Observation: type Id = lucuma.core.model.Observation.Id diff --git a/model/shared/src/main/scala/explore/model/OnAsterismUpdateParams.scala b/model/shared/src/main/scala/explore/model/OnAsterismUpdateParams.scala new file mode 100644 index 0000000000..2c64565516 --- /dev/null +++ b/model/shared/src/main/scala/explore/model/OnAsterismUpdateParams.scala @@ -0,0 +1,30 @@ +// 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.model + +import cats.Eq +import cats.derived.* +import lucuma.core.model.Target + +/** + * Parameters for onAsterismUpdate functions, which are called when a target is added or removed + * from an asterism, and also during undo/redo. + * + * @param target + * The id of the target that was added or removed. + * @param obsIds + * The observation ids whose asterism was updated. + * @param isAddAction + * Whether the original action was a add action (versus a remove action) + * @param areAddingTarget + * True if the target is currently being added. + */ +case class OnAsterismUpdateParams( + targetId: Target.Id, + obsIds: ObsIdSet, + isAddAction: Boolean, + areAddingTarget: Boolean +) derives Eq: + // Whether this is a undo, versus an original action or a redo + val isUndo = isAddAction != areAddingTarget diff --git a/model/shared/src/main/scala/explore/model/TargetEditCloneInfo.scala b/model/shared/src/main/scala/explore/model/TargetEditCloneInfo.scala index 7cc0cb8d60..0258b99f53 100644 --- a/model/shared/src/main/scala/explore/model/TargetEditCloneInfo.scala +++ b/model/shared/src/main/scala/explore/model/TargetEditCloneInfo.scala @@ -56,12 +56,14 @@ object TargetEditCloneInfo: val onlyCurrentMsg: NonEmptyString = "only the current observations".refined val allCurrentExecutedMsg: NonEmptyString = "All the current observations have been executed. Target is readonly.".refined + val thisExecutedMsg: NonEmptyString = + "The current observation has been executed. Target is readonly.".refined val allForTargetExecutedMsg: NonEmptyString = "All associated observations have been executed. Target is readonly".refined val onlyUnexecutedMsg: NonEmptyString = "Target will only be modified for the un-executed observations.".refined val someExecutedMsg: NonEmptyString = - "Some of the observations being edited have been executed.".refined + "Some of the observations being edited have been executed. Edits should apply to ".refined val unexecutedOfCurrentMsg: NonEmptyString = "unexecuted observations of the current asterism".refined val allUnexectedMsg: NonEmptyString = "all unexecuted observations".refined @@ -77,12 +79,15 @@ object TargetEditCloneInfo: def allOtherExecutedText: NonEmptyString = if (obsIds.size === 1) allThisMsg else allCurrentMsg + def allExecutedMsg: NonEmptyString = + if (obsIds.size === 1) thisExecutedMsg + else allCurrentExecutedMsg def otherMessage(otherCount: Long, hasExecuted: Boolean): NonEmptyString = val plural = if (otherCount === 1) "" else "s" val ex = if (hasExecuted) "unexecuted " else "" NonEmptyString.unsafeFrom( - s"Target is in $otherCount other ${ex}observation$plural. Edits here should apply to." + s"Target is in $otherCount other ${ex}observation$plural. Edits here should apply to " ) def allForTargetMsg(hasExecuted: Boolean): NonEmptyString = @@ -101,8 +106,8 @@ object TargetEditCloneInfo: onlyUnexecutedMsg, obsInfo.unexecutedForTarget ) - case Some(_) if obsInfo.allCurrentAreExecuted => - TargetEditCloneInfo.readonly(allCurrentExecutedMsg) + case Some(editing) if obsInfo.allCurrentAreExecuted => + TargetEditCloneInfo.readonly(editing.allExecutedMsg) // There are no other unexecuted observations, but maybe there are others. case Some(editing) if obsInfo.otherUnexecutedObsCount === 0 => if (obsInfo.allCurrentAreOK && obsInfo.otherObsCount === 0) diff --git a/model/shared/src/main/scala/explore/model/TargetEditObsInfo.scala b/model/shared/src/main/scala/explore/model/TargetEditObsInfo.scala index 32b38b29cd..dd7f3d463c 100644 --- a/model/shared/src/main/scala/explore/model/TargetEditObsInfo.scala +++ b/model/shared/src/main/scala/explore/model/TargetEditObsInfo.scala @@ -6,7 +6,7 @@ package explore.model import cats.Eq import cats.derived.* import cats.syntax.all.* -import lucuma.core.enums.ObsStatus +import explore.model.syntax.all.* import lucuma.core.model.Target /** @@ -51,16 +51,11 @@ object TargetEditObsInfo: current: Option[ObsIdSet], summaries: ProgramSummaries ): TargetEditObsInfo = - val allObsIds = summaries.targetsWithObs.get(tid).map(_.obsIds) + val allObsIds = summaries.targetsWithObs.get(tid).map(_.obsIds).flatMap(ObsIdSet.fromSortedSet) // we should always find the ids in `observations` - val executed = - allObsIds.map( - _.filter(id => - summaries.observations.getValue(id).fold(false)(_.status >= ObsStatus.Ongoing) - ) - ) + val executed = allObsIds.flatMap(summaries.observations.executedOf) TargetEditObsInfo( current, - allObsIds.flatMap(ObsIdSet.fromSortedSet), - executed.flatMap(ObsIdSet.fromSortedSet) + allObsIds, + executed ) diff --git a/model/shared/src/main/scala/explore/model/package.scala b/model/shared/src/main/scala/explore/model/package.scala index 6326b07607..10d80d6d1e 100644 --- a/model/shared/src/main/scala/explore/model/package.scala +++ b/model/shared/src/main/scala/explore/model/package.scala @@ -48,6 +48,9 @@ val EmptySiderealTarget = type AsterismIds = SortedSet[Target.Id] +object AsterismIds: + val empty: AsterismIds = SortedSet.empty[Target.Id] + type AsterismGroupList = SortedMap[ObsIdSet, AsterismIds] type TargetList = SortedMap[Target.Id, Target] type TargetWithObsList = SortedMap[Target.Id, TargetWithObs] diff --git a/model/shared/src/main/scala/explore/model/syntax/package.scala b/model/shared/src/main/scala/explore/model/syntax/package.scala index 28abbc441a..4cd367d172 100644 --- a/model/shared/src/main/scala/explore/model/syntax/package.scala +++ b/model/shared/src/main/scala/explore/model/syntax/package.scala @@ -53,6 +53,27 @@ object all: def findWithTargetIds(targetIds: SortedSet[Target.Id]): Option[AsterismGroup] = self.find { case (_, grpIds) => grpIds === targetIds }.map(AsterismGroup.fromTuple) + extension (observations: ObservationList) + def executedOf(obsIds: ObsIdSet): Option[ObsIdSet] = + val executed = obsIds.idSet.filter(id => observations.getValue(id).fold(false)(_.isExecuted)) + ObsIdSet.fromSortedSet(executed) + def addTargetToObservations(targetId: Target.Id, obsIds: ObsIdSet): ObservationList = + obsIds.idSet.foldLeft(observations)((list, obsId) => + list.updatedValueWith(obsId, Observation.scienceTargetIds.modify(_ + targetId)) + ) + def removeTargetFromObservations(targetId: Target.Id, obsIds: ObsIdSet): ObservationList = + obsIds.idSet.foldLeft(observations)((list, obsId) => + list.updatedValueWith(obsId, Observation.scienceTargetIds.modify(_ - targetId)) + ) + def allWithTarget(targetId: Target.Id): Set[Observation.Id] = + observations.values + .filter(_.scienceTargetIds.contains(targetId)) + .map(_.id) + .toSet + // determine if the target is in any other observations other than the ones in obsIds + def isTargetInOtherObs(targetId: Target.Id, obsIds: ObsIdSet): Boolean = + (allWithTarget(targetId) -- obsIds.idSet.toSortedSet).nonEmpty + extension (self: ConstraintGroupList) @targetName("findContainingObsIdsCS") def findContainingObsIds(obsIds: ObsIdSet): Option[ConstraintGroup] =