Repository: OpenSpace/OpenSpace-WebGui
Pull request #75: add mission selection if multiple ones are loaded
Author: login: engbergandreas
Created: 2025-03-03T11:37:46Z | Merged: 2025-03-12T11:43:41Z

Requires latest OpenSpace gui branch [here](https://github.com/OpenSpace/OpenSpace/commit/9fa57a47b8b9142a97d2f25934dafda5edd57642)

You can now load multiple mission files and select which one you want to view. 
Remembers which mission was last viewed if the panel is closed
Keep the same mission viewed if more are loaded at runtime
Fallback to first mission file in list if the one viewed is removed



Diff:
diff --git a/src/panels/MissionsPanel/MissionContent.tsx b/src/panels/MissionsPanel/MissionContent.tsx
index 34ced128..dd637f5c 100644
--- a/src/panels/MissionsPanel/MissionContent.tsx
+++ b/src/panels/MissionsPanel/MissionContent.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import { Box, Button, Group, Switch, Text, Title } from '@mantine/core';
 
 import { useSubscribeToTime } from '@/api/hooks';
@@ -23,6 +23,21 @@ export function MissionContent({ missionOverview }: Props) {
     data: undefined
   });
 
+  // Reset phases when selected mission is changed
+  useEffect(() => {
+    // When the mission is changed, display overview again
+    setDisplayedPhase({
+      type: DisplayType.Overview,
+      data: missionOverview
+    });
+    // Avoid potentially showing information from a previous mission
+    setLastDisplayedPhase({
+      type: DisplayType.Overview,
+      data: missionOverview
+    });
+    setDisplayCurrentPhase(false);
+  }, [missionOverview]);
+
   const now = useSubscribeToTime();
 
   const allPhasesNested = useMemo(() => {
@@ -104,14 +119,14 @@ export function MissionContent({ missionOverview }: Props) {
   }
 
   return (
-    <Group wrap={'nowrap'} align={'start'} h={'100%'} gap={0}>
+    <Group wrap={'nowrap'} align={'start'} gap={'xs'}>
       <TimeLine
         allPhasesNested={allPhasesNested}
         displayedPhase={displayedPhase}
         missionOverview={missionOverview}
         setDisplayedPhase={setPhaseManually}
       />
-      <Box px={'md'} h={'100%'} style={{ overflow: 'auto' }}>
+      <Box flex={1}>
         <Group justify={'space-between'} mb={'md'}>
           <Title order={2}>{missionOverview.name}</Title>
           <Button
diff --git a/src/panels/MissionsPanel/MissionsPanel.tsx b/src/panels/MissionsPanel/MissionsPanel.tsx
index 981b2f4b..482896ab 100644
--- a/src/panels/MissionsPanel/MissionsPanel.tsx
+++ b/src/panels/MissionsPanel/MissionsPanel.tsx
@@ -1,13 +1,29 @@
-import { Stack, Text, ThemeIcon, Title } from '@mantine/core';
+import { Box, Select, Stack, Text, ThemeIcon, Title } from '@mantine/core';
 
+import { useOpenSpaceApi } from '@/api/hooks';
 import { RocketLaunchIcon } from '@/icons/icons';
-import { useAppSelector } from '@/redux/hooks';
+import { useAppDispatch, useAppSelector } from '@/redux/hooks';
+import { setSelectedMission } from '@/redux/missions/missionsSlice';
 
 import { MissionContent } from './MissionContent';
 
 export function MissionsPanel() {
-  const hasMission = useAppSelector((state) => state.missions.isInitialized);
-  const mission = useAppSelector((state) => state.missions.data.missions[0]);
+  const { isInitialized, missions, selectedMissionIdentifier } = useAppSelector(
+    (state) => state.missions
+  );
+
+  const luaApi = useOpenSpaceApi();
+  const dispatch = useAppDispatch();
+
+  const hasMission = isInitialized && Object.values(missions).length > 0;
+
+  function onMissionSelected(identifier: string | null) {
+    if (!identifier) {
+      return;
+    }
+    dispatch(setSelectedMission(identifier));
+    luaApi?.setCurrentMission(identifier);
+  }
 
   if (!hasMission) {
     return (
@@ -23,5 +39,25 @@ export function MissionsPanel() {
     );
   }
 
-  return <MissionContent missionOverview={mission} />;
+  // Grab the mission last viewed or if it does not exist anymore, take the first one
+  const mission = missions[selectedMissionIdentifier] ?? Object.values(missions)[0];
+
+  return (
+    <Box>
+      {Object.keys(missions).length > 1 && (
+        <Select
+          label={'Selected mission'}
+          placeholder={'Select a mission'}
+          data={Object.entries(missions).map(([identifier, mission]) => {
+            return { value: identifier, label: mission.name };
+          })}
+          value={selectedMissionIdentifier}
+          onChange={onMissionSelected}
+          my={'xs'}
+          allowDeselect={false}
+        />
+      )}
+      <MissionContent missionOverview={mission} />
+    </Box>
+  );
 }
diff --git a/src/panels/MissionsPanel/types.ts b/src/panels/MissionsPanel/types.ts
index bd918d38..8962fed2 100644
--- a/src/panels/MissionsPanel/types.ts
+++ b/src/panels/MissionsPanel/types.ts
@@ -1,3 +1,5 @@
+import { Identifier } from '@/types/types';
+
 export interface Milestone {
   date: string; // Date as an UTC ISO8601 string
   name: string;
@@ -19,6 +21,8 @@ export interface Phase {
   capturetimes: string[];
 }
 
+export type Missions = Record<Identifier, Phase>;
+
 export type DisplayedPhase =
   | { type: DisplayType.Phase; data: Phase }
   | { type: DisplayType.Overview; data: Phase }
diff --git a/src/redux/events/eventsMiddleware.ts b/src/redux/events/eventsMiddleware.ts
index 1ea02cfb..cee22d78 100644
--- a/src/redux/events/eventsMiddleware.ts
+++ b/src/redux/events/eventsMiddleware.ts
@@ -12,6 +12,8 @@ import {
 } from '@/redux/propertytree/propertyTreeMiddleware';
 import { ConnectionStatus } from '@/types/enums';
 
+import { refreshMissions } from '../missions/missionsMiddleware';
+
 import { EventData } from './types';
 
 let eventTopic: Topic;
@@ -42,6 +44,10 @@ export const setupEventsSubscription = createAsyncThunk(
         case 'ActionRemoved':
           thunkAPI.dispatch(removeAction(data.Uri));
           break;
+        case 'MissionAdded':
+        case 'MissionRemoved':
+          thunkAPI.dispatch(refreshMissions());
+          break;
         default:
           break;
       }
diff --git a/src/redux/events/types.ts b/src/redux/events/types.ts
index 9680b4ea..83f2b541 100644
--- a/src/redux/events/types.ts
+++ b/src/redux/events/types.ts
@@ -64,10 +64,20 @@ type InterpolationFinishedEvent = {
   Property: Uri;
 };
 
+type MissionAddedEvent = {
+  Event: 'MissionAdded';
+  MissionName: string;
+};
+
 type MissionEventReachedEvent = {
   Event: 'MissionEventReached';
 };
 
+type MissionRemovedEvent = {
+  Event: 'MissionRemoved';
+  MissionName: string;
+};
+
 type ParallelConnectionEvent = {
   Event: 'ParallelConnection';
   State: 'Established' | 'Lost' | 'HostshipGained' | 'HostshipLost';
@@ -134,7 +144,9 @@ export type EventData =
   | FocusNodeChangedEvent
   | GuiTreeUpdatedEvent
   | InterpolationFinishedEvent
+  | MissionAddedEvent
   | MissionEventReachedEvent
+  | MissionRemovedEvent
   | ParallelConnectionEvent
   | PlanetEclipsedEvent
   | PointSpacecraftEvent
diff --git a/src/redux/missions/missionsMiddleware.ts b/src/redux/missions/missionsMiddleware.ts
index 8157691b..60e1469e 100644
--- a/src/redux/missions/missionsMiddleware.ts
+++ b/src/redux/missions/missionsMiddleware.ts
@@ -1,33 +1,43 @@
+import { createAction, createAsyncThunk } from '@reduxjs/toolkit';
+
 import { api } from '@/api/api';
-import { Phase } from '@/panels/MissionsPanel/types';
+import { Missions } from '@/panels/MissionsPanel/types';
 import { onOpenConnection } from '@/redux/connection/connectionSlice';
 import type { AppStartListening } from '@/redux/listenerMiddleware';
 
-import { initializeMissions } from './missionsSlice';
+import { clearMissions, initializeMissions } from './missionsSlice';
+
+export const refreshMissions = createAction<void>('missions/refreshMissions');
 
 interface MissionData {
   done: boolean;
   value: {
-    missions: Phase[] | null;
+    missions: Missions | null;
   };
 }
 
-async function getMissions(callback: (missions: { missions: Phase[] }) => void) {
+const getMissions = createAsyncThunk('missions/getMissions', async (_, thunkAPI) => {
   const missionTopic = api.startTopic('missions', {});
   const { value } = (await missionTopic.iterator().next()) as MissionData;
-  if (value.missions !== null) {
-    callback({ missions: value.missions });
+  if (value.missions) {
+    thunkAPI.dispatch(initializeMissions(value.missions));
+  } else {
+    thunkAPI.dispatch(clearMissions());
   }
-  missionTopic.cancel();
-}
+});
 
 export const addMissionsListener = (startListening: AppStartListening) => {
   startListening({
     actionCreator: onOpenConnection,
     effect: (_, api) => {
-      getMissions((missions) => {
-        api.dispatch(initializeMissions(missions));
-      });
+      api.dispatch(getMissions());
+    }
+  });
+
+  startListening({
+    actionCreator: refreshMissions,
+    effect: (_, api) => {
+      api.dispatch(getMissions());
     }
   });
 };
diff --git a/src/redux/missions/missionsSlice.ts b/src/redux/missions/missionsSlice.ts
index 869601a7..83254f70 100644
--- a/src/redux/missions/missionsSlice.ts
+++ b/src/redux/missions/missionsSlice.ts
@@ -1,17 +1,17 @@
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 
-import { Phase } from '@/panels/MissionsPanel/types';
+import { Missions, Phase } from '@/panels/MissionsPanel/types';
 
 export interface MissionState {
   isInitialized: boolean;
-  data: { missions: Phase[] };
+  missions: Missions;
+  selectedMissionIdentifier: string;
 }
 
 const initialState: MissionState = {
   isInitialized: false,
-  data: {
-    missions: []
-  }
+  missions: {},
+  selectedMissionIdentifier: ''
 };
 
 function makeUTCString(time: string): string {
@@ -41,14 +41,38 @@ export const missionsSlice = createSlice({
   name: 'missions',
   initialState,
   reducers: {
-    initializeMissions: (state, action: PayloadAction<{ missions: Phase[] }>) => {
+    initializeMissions: (state, action: PayloadAction<Missions>) => {
+      // Empty the existing map so that we don't keep removed missions in redux state
+      state.missions = {};
       state.isInitialized = true;
-      state.data.missions = action.payload.missions.map(convertPhaseToUTC);
+
+      Object.entries(action.payload).map(([identifier, mission]) => {
+        state.missions[identifier] = convertPhaseToUTC(mission);
+      });
+
+      // If no mission was loaded or if the previously selected mission was removed,
+      // automatically select first available mission from the updated list if one exists
+      const isSelectedMissionLoaded = state.selectedMissionIdentifier in state.missions;
+      if (!isSelectedMissionLoaded) {
+        state.selectedMissionIdentifier = Object.keys(state.missions)[0] ?? '';
+      }
+
+      return state;
+    },
+    clearMissions: (state) => {
+      state.isInitialized = false;
+      state.missions = {};
+      state.selectedMissionIdentifier = '';
+      return state;
+    },
+    setSelectedMission: (state, action: PayloadAction<string>) => {
+      state.selectedMissionIdentifier = action.payload;
       return state;
     }
   }
 });
 
 // Action creators are generated for each case reducer function, replaces the `Actions/index.js`
-export const { initializeMissions } = missionsSlice.actions;
+export const { initializeMissions, clearMissions, setSelectedMission } =
+  missionsSlice.actions;
 export const missionsReducer = missionsSlice.reducer;


Reviews:
user: login: engbergandreas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T11:40:39Z
user: login: engbergandreas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T11:41:46Z
user: login: ylvaselling
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-04T07:25:43Z
user: login: engbergandreas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T09:07:55Z
user: login: engbergandreas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T09:09:19Z
user: login: engbergandreas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T10:53:52Z
user: login: engbergandreas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T11:07:19Z
user: login: WeirdRubberDuck
body: Nice!  Eventually, we should consider adding a popup or something when a new missing file is loaded, so the user becomes aware of the change. But this is already a great improvement :) 

I saw Ylva's comment about potentially having the dropdown at the top at all files. Personally, I would prefer not to, since loading multiple missions files could still quite an unusual/special situation, and it takes unnecessary screen real-estate
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-05T15:59:02Z
user: login: WeirdRubberDuck
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T16:01:02Z
user: login: WeirdRubberDuck
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T16:02:04Z
user: login: WeirdRubberDuck
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T16:03:12Z
user: login: ylvaselling
body: Overall looks great! Just found one out of bounds in an array otherwise 👍 
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T07:11:15Z

Review comments:
diff_hunk: @@ -104,14 +118,14 @@ export function MissionContent({ missionOverview }: Props) {
   }
 
   return (
-    <Group wrap={'nowrap'} align={'start'} h={'100%'} gap={0}>
+    <Group wrap={'nowrap'} align={'start'} gap={'xs'}>
       <TimeLine
         allPhasesNested={allPhasesNested}
         displayedPhase={displayedPhase}
         missionOverview={missionOverview}
         setDisplayedPhase={setPhaseManually}
       />
-      <Box px={'md'} h={'100%'} style={{ overflow: 'auto' }}>
path: src/panels/MissionsPanel/MissionContent.tsx
user: login: engbergandreas
body: I couldn't see any noticeable differences with the height and overflow css here, so I removed it (is it legacy from before @ylvaselling  refactor?) and moved the padding to the parent gap prop instead
created_at: 2025-03-03T11:40:39Z
author_association: MEMBER
diff_hunk: @@ -1,13 +1,41 @@
-import { Stack, Text, ThemeIcon, Title } from '@mantine/core';
+import { Box, Select, Stack, Text, ThemeIcon, Title } from '@mantine/core';
 
+import { useOpenSpaceApi } from '@/api/hooks';
 import { RocketLaunchIcon } from '@/icons/icons';
-import { useAppSelector } from '@/redux/hooks';
+import { useAppDispatch, useAppSelector } from '@/redux/hooks';
+import { setSelectedMission } from '@/redux/missions/missionsSlice';
 
 import { MissionContent } from './MissionContent';
 
 export function MissionsPanel() {
-  const hasMission = useAppSelector((state) => state.missions.isInitialized);
-  const mission = useAppSelector((state) => state.missions.data.missions[0]);
+  const {
+    isInitialized: hasMission,
+    data,
+    selectedMissionIdentifier
+  } = useAppSelector((state) => state.missions);
+
+  const luaApi = useOpenSpaceApi();
+  const dispatch = useAppDispatch();
+
+  let [mission] = data.missions;
+  // If a new missions is added, the missions order might have changed so we try to find
path: src/panels/MissionsPanel/MissionsPanel.tsx
user: login: engbergandreas
body: ```suggestion
  // If a new mission is added, the missions order might have changed, so we try to find
```
created_at: 2025-03-03T11:41:46Z
author_association: MEMBER
diff_hunk: @@ -1,13 +1,41 @@
-import { Stack, Text, ThemeIcon, Title } from '@mantine/core';
+import { Box, Select, Stack, Text, ThemeIcon, Title } from '@mantine/core';
 
+import { useOpenSpaceApi } from '@/api/hooks';
 import { RocketLaunchIcon } from '@/icons/icons';
-import { useAppSelector } from '@/redux/hooks';
+import { useAppDispatch, useAppSelector } from '@/redux/hooks';
+import { setSelectedMission } from '@/redux/missions/missionsSlice';
 
 import { MissionContent } from './MissionContent';
 
 export function MissionsPanel() {
-  const hasMission = useAppSelector((state) => state.missions.isInitialized);
-  const mission = useAppSelector((state) => state.missions.data.missions[0]);
+  const {
+    isInitialized: hasMission,
+    data,
+    selectedMissionIdentifier
+  } = useAppSelector((state) => state.missions);
+
+  const luaApi = useOpenSpaceApi();
+  const dispatch = useAppDispatch();
+
+  let [mission] = data.missions;
path: src/panels/MissionsPanel/MissionsPanel.tsx
user: login: ylvaselling
body: Personally I would prefer using this type of restructuring for hooks 
```suggestion
  let mission = data.missions.at(0);
```
created_at: 2025-03-04T07:00:55Z
author_association: COLLABORATOR
diff_hunk: @@ -1,13 +1,41 @@
-import { Stack, Text, ThemeIcon, Title } from '@mantine/core';
+import { Box, Select, Stack, Text, ThemeIcon, Title } from '@mantine/core';
 
+import { useOpenSpaceApi } from '@/api/hooks';
 import { RocketLaunchIcon } from '@/icons/icons';
-import { useAppSelector } from '@/redux/hooks';
+import { useAppDispatch, useAppSelector } from '@/redux/hooks';
+import { setSelectedMission } from '@/redux/missions/missionsSlice';
 
 import { MissionContent } from './MissionContent';
 
 export function MissionsPanel() {
-  const hasMission = useAppSelector((state) => state.missions.isInitialized);
-  const mission = useAppSelector((state) => state.missions.data.missions[0]);
+  const {
+    isInitialized: hasMission,
+    data,
+    selectedMissionIdentifier
+  } = useAppSelector((state) => state.missions);
+
+  const luaApi = useOpenSpaceApi();
+  const dispatch = useAppDispatch();
+
+  let [mission] = data.missions;
+  // If a new missions is added, the missions order might have changed so we try to find
+  // the last one viewed
+  if (hasMission && selectedMissionIdentifier) {
+    const selectedMission = data.missions.find(
+      (_mission) => _mission.identifier === selectedMissionIdentifier
+    );
+    if (selectedMission) {
+      mission = selectedMission;
+    }
+  }
path: src/panels/MissionsPanel/MissionsPanel.tsx
user: login: ylvaselling
body: It seems like the selectedMission takes precedence over getting the first mission, so how about we make this a function / hook and return the index 0 in an else statement? That would make it clearer that we first check for the selected mission and then as a fallback the first element in the array. Plus the hook could return { hasMission, mission } and it would look kinda neat I think
```suggestion
  if (hasMission && selectedMissionIdentifier) {
    const selectedMission = data.missions.find(
      (_mission) => _mission.identifier === selectedMissionIdentifier
    );
    if (selectedMission) {
      mission = selectedMission;
    }
    else {
        mission = data.missions.at(0);
    }
  }
```
created_at: 2025-03-04T07:04:34Z
author_association: COLLABORATOR
diff_hunk: @@ -64,10 +64,20 @@ type InterpolationFinishedEvent = {
   Property: Uri;
 };
 
+type MissionAddedEvent = {
+  Event: 'MissionAdded';
+  MissionName: string;
+};
+
 type MissionEventReachedEvent = {
   Event: 'MissionEventReached';
 };
 
+type MissionRemovedEvent = {
+  Event: 'MissionRemoved';
+  MissionName: string;
+};
+
path: src/redux/events/types.ts
user: login: ylvaselling
body: Place these two next to each other?
created_at: 2025-03-04T07:06:12Z
author_association: COLLABORATOR
diff_hunk: @@ -12,12 +16,15 @@ interface MissionData {
   };
 }
 
-async function getMissions(callback: (missions: { missions: Phase[] }) => void) {
+async function getMissions(callback: (missions: { missions: Phase[] } | null) => void) {
   const missionTopic = api.startTopic('missions', {});
   const { value } = (await missionTopic.iterator().next()) as MissionData;
-  if (value.missions !== null) {
+  if (value.missions) {
     callback({ missions: value.missions });
+  } else {
+    callback(null);
   }
+
path: src/redux/missions/missionsMiddleware.ts
user: login: ylvaselling
body: Can we place the code in the `getMissions` function inside of the `effect`? This is kind of hard to read atm
created_at: 2025-03-04T07:09:09Z
author_association: COLLABORATOR
diff_hunk: @@ -26,7 +33,22 @@ export const addMissionsListener = (startListening: AppStartListening) => {
     actionCreator: onOpenConnection,
     effect: (_, api) => {
       getMissions((missions) => {
-        api.dispatch(initializeMissions(missions));
+        if (missions) {
+          api.dispatch(initializeMissions(missions));
+        }
+      });
+    }
+  });
+
+  startListening({
+    actionCreator: refreshMissions,
+    effect: (_, api) => {
+      getMissions((missions) => {
+        if (missions) {
+          api.dispatch(initializeMissions(missions));
+        } else {
+          api.dispatch(clearMissions());
+        }
path: src/redux/missions/missionsMiddleware.ts
user: login: ylvaselling
body: Why don't the openconnection and the refreshmissions have the same callback? Shouldn't they both do the same thing?
created_at: 2025-03-04T07:10:31Z
author_association: COLLABORATOR
diff_hunk: @@ -44,11 +46,31 @@ export const missionsSlice = createSlice({
     initializeMissions: (state, action: PayloadAction<{ missions: Phase[] }>) => {
       state.isInitialized = true;
       state.data.missions = action.payload.missions.map(convertPhaseToUTC);
+
+      // If no mission was loaded or if the previously selected mission was removed,
+      // automatically select first available mission from the updated list
+      const isSelectedMissionLoaded = state.data.missions.some(
+        (mission) => mission.identifier === state.selectedMissionIdentifier
+      );
+      if (!isSelectedMissionLoaded) {
+        state.selectedMissionIdentifier = action.payload.missions[0].identifier;
+      }
+      return state;
path: src/redux/missions/missionsSlice.ts
user: login: ylvaselling
body: Now that it is possible to have multipe missions, maybe we want to store them as a map with a key first? that way the finds are easier
created_at: 2025-03-04T07:13:05Z
author_association: COLLABORATOR
diff_hunk: @@ -23,6 +23,20 @@ export function MissionContent({ missionOverview }: Props) {
     data: undefined
   });
 
path: src/panels/MissionsPanel/MissionContent.tsx
user: login: ylvaselling
body: Make a comment here that describes that this resets the phases when the selected mission has changed 🙏 
created_at: 2025-03-04T07:15:44Z
author_association: COLLABORATOR
diff_hunk: @@ -23,5 +51,22 @@ export function MissionsPanel() {
     );
   }
 
-  return <MissionContent missionOverview={mission} />;
+  return (
+    <Box>
+      {data.missions.length > 1 && (
+        <Select
+          label={'Selected mission'}
+          placeholder={'Select a mission'}
+          data={data.missions.map((_mission) => {
+            return { value: _mission.identifier, label: _mission.name };
+          })}
path: src/panels/MissionsPanel/MissionsPanel.tsx
user: login: ylvaselling
body: Personally I'm not a huge fan of the `_variableName` style. It looks to me like the member variables in OpenSpace, and for some reason I also find it hard to read... I would prefer a shorter name here like `entry`, `mis` , `element` or whatever 
created_at: 2025-03-04T07:24:54Z
author_association: COLLABORATOR
diff_hunk: @@ -17,6 +17,8 @@ export interface Phase {
   link: string;
   milestones: Milestone[];
   capturetimes: string[];
+  identifier: string; // TOOD anden88 2025-03-03: The identifier only exists on the outermost
path: src/panels/MissionsPanel/types.ts
user: login: ylvaselling
body: Should this be of type Identifier?
created_at: 2025-03-04T07:25:22Z
author_association: COLLABORATOR
diff_hunk: @@ -1,13 +1,41 @@
-import { Stack, Text, ThemeIcon, Title } from '@mantine/core';
+import { Box, Select, Stack, Text, ThemeIcon, Title } from '@mantine/core';
 
+import { useOpenSpaceApi } from '@/api/hooks';
 import { RocketLaunchIcon } from '@/icons/icons';
-import { useAppSelector } from '@/redux/hooks';
+import { useAppDispatch, useAppSelector } from '@/redux/hooks';
+import { setSelectedMission } from '@/redux/missions/missionsSlice';
 
 import { MissionContent } from './MissionContent';
 
 export function MissionsPanel() {
-  const hasMission = useAppSelector((state) => state.missions.isInitialized);
-  const mission = useAppSelector((state) => state.missions.data.missions[0]);
+  const {
+    isInitialized: hasMission,
+    data,
+    selectedMissionIdentifier
+  } = useAppSelector((state) => state.missions);
+
+  const luaApi = useOpenSpaceApi();
+  const dispatch = useAppDispatch();
+
+  let [mission] = data.missions;
path: src/panels/MissionsPanel/MissionsPanel.tsx
user: login: engbergandreas
body: at first I did `data.missions[0]` but the linter screamed at me, I know we've done this in several other places so we should be consistent if we want to do the `at(0)` version
created_at: 2025-03-04T09:07:55Z
author_association: MEMBER
diff_hunk: @@ -64,10 +64,20 @@ type InterpolationFinishedEvent = {
   Property: Uri;
 };
 
+type MissionAddedEvent = {
+  Event: 'MissionAdded';
+  MissionName: string;
+};
+
 type MissionEventReachedEvent = {
   Event: 'MissionEventReached';
 };
 
+type MissionRemovedEvent = {
+  Event: 'MissionRemoved';
+  MissionName: string;
+};
+
path: src/redux/events/types.ts
user: login: engbergandreas
body: Sorted in alphabetical order
created_at: 2025-03-04T09:09:19Z
author_association: MEMBER
diff_hunk: @@ -1,13 +1,41 @@
-import { Stack, Text, ThemeIcon, Title } from '@mantine/core';
+import { Box, Select, Stack, Text, ThemeIcon, Title } from '@mantine/core';
 
+import { useOpenSpaceApi } from '@/api/hooks';
 import { RocketLaunchIcon } from '@/icons/icons';
-import { useAppSelector } from '@/redux/hooks';
+import { useAppDispatch, useAppSelector } from '@/redux/hooks';
+import { setSelectedMission } from '@/redux/missions/missionsSlice';
 
 import { MissionContent } from './MissionContent';
 
 export function MissionsPanel() {
-  const hasMission = useAppSelector((state) => state.missions.isInitialized);
-  const mission = useAppSelector((state) => state.missions.data.missions[0]);
+  const {
+    isInitialized: hasMission,
+    data,
+    selectedMissionIdentifier
+  } = useAppSelector((state) => state.missions);
+
+  const luaApi = useOpenSpaceApi();
+  const dispatch = useAppDispatch();
+
+  let [mission] = data.missions;
+  // If a new missions is added, the missions order might have changed so we try to find
+  // the last one viewed
+  if (hasMission && selectedMissionIdentifier) {
+    const selectedMission = data.missions.find(
+      (_mission) => _mission.identifier === selectedMissionIdentifier
+    );
+    if (selectedMission) {
+      mission = selectedMission;
+    }
+  }
path: src/panels/MissionsPanel/MissionsPanel.tsx
user: login: engbergandreas
body: Checkout the new hook pls and thx
created_at: 2025-03-05T10:53:52Z
author_association: MEMBER
diff_hunk: @@ -44,11 +46,31 @@ export const missionsSlice = createSlice({
     initializeMissions: (state, action: PayloadAction<{ missions: Phase[] }>) => {
       state.isInitialized = true;
       state.data.missions = action.payload.missions.map(convertPhaseToUTC);
+
+      // If no mission was loaded or if the previously selected mission was removed,
+      // automatically select first available mission from the updated list
+      const isSelectedMissionLoaded = state.data.missions.some(
+        (mission) => mission.identifier === state.selectedMissionIdentifier
+      );
+      if (!isSelectedMissionLoaded) {
+        state.selectedMissionIdentifier = action.payload.missions[0].identifier;
+      }
+      return state;
path: src/redux/missions/missionsSlice.ts
user: login: engbergandreas
body: I hear ya, I'm wondering if it is worth the extra overhead when receiving the data from OpenSpace since we will, for the most part, have 1 mission loaded. If users have loaded multiple ones, I doubt it will be more than 2-3 at most?

by overhead I mean more how the code looks, looking at the `addPropertyOwners` in `propertyOwnerSlice` for example
created_at: 2025-03-05T11:07:19Z
author_association: MEMBER
diff_hunk: @@ -23,5 +37,22 @@ export function MissionsPanel() {
     );
   }
 
-  return <MissionContent missionOverview={mission} />;
+  return (
+    <Box>
+      {data.missions.length > 1 && (
+        <Select
+          label={'Selected mission'}
+          placeholder={'Select a mission'}
+          data={data.missions.map((mission) => {
+            return { value: mission.identifier, label: mission.name };
+          })}
+          value={selectedMissionIdentifier}
+          onChange={onMissionSelected}
+          my={'xs'}
+          allowDeselect={false}
+        />
+      )}
+      <MissionContent missionOverview={mission!} />
path: src/panels/MissionsPanel/MissionsPanel.tsx
user: login: WeirdRubberDuck
body: ```suggestion
      <MissionContent missionOverview={mission} />
```

and move the check to the if statement above? See other comment
created_at: 2025-03-05T15:43:18Z
author_association: COLLABORATOR
diff_hunk: @@ -1,13 +1,27 @@
-import { Stack, Text, ThemeIcon, Title } from '@mantine/core';
+import { Box, Select, Stack, Text, ThemeIcon, Title } from '@mantine/core';
 
+import { useOpenSpaceApi } from '@/api/hooks';
 import { RocketLaunchIcon } from '@/icons/icons';
-import { useAppSelector } from '@/redux/hooks';
+import { useAppDispatch, useAppSelector } from '@/redux/hooks';
+import { setSelectedMission } from '@/redux/missions/missionsSlice';
 
+import { useSelectedMission } from './hooks';
 import { MissionContent } from './MissionContent';
 
 export function MissionsPanel() {
-  const hasMission = useAppSelector((state) => state.missions.isInitialized);
-  const mission = useAppSelector((state) => state.missions.data.missions[0]);
+  const { data, selectedMissionIdentifier } = useAppSelector((state) => state.missions);
+
+  const luaApi = useOpenSpaceApi();
+  const { hasMission, mission } = useSelectedMission();
+  const dispatch = useAppDispatch();
+
+  function onMissionSelected(identifier: string | null) {
+    if (!identifier) {
+      return;
+    }
+    dispatch(setSelectedMission(identifier));
+    luaApi?.setCurrentMission(identifier);
+  }
 
   if (!hasMission) {
path: src/panels/MissionsPanel/MissionsPanel.tsx
user: login: WeirdRubberDuck
body: ```suggestion
  if (!hasMission || !mission) {
```
and remove exclamation mark below
created_at: 2025-03-05T15:43:34Z
author_association: COLLABORATOR
diff_hunk: @@ -5,13 +5,15 @@ import { Phase } from '@/panels/MissionsPanel/types';
 export interface MissionState {
   isInitialized: boolean;
   data: { missions: Phase[] };
+  selectedMissionIdentifier: string;
 }
 
 const initialState: MissionState = {
   isInitialized: false,
   data: {
     missions: []
-  }
+  },
+  selectedMissionIdentifier: ''
path: src/redux/missions/missionsSlice.ts
user: login: WeirdRubberDuck
body: do we want to use empty or undefined here?  
created_at: 2025-03-05T15:45:25Z
author_association: COLLABORATOR
diff_hunk: @@ -44,11 +46,31 @@ export const missionsSlice = createSlice({
     initializeMissions: (state, action: PayloadAction<{ missions: Phase[] }>) => {
       state.isInitialized = true;
       state.data.missions = action.payload.missions.map(convertPhaseToUTC);
+
+      // If no mission was loaded or if the previously selected mission was removed,
+      // automatically select first available mission from the updated list
+      const isSelectedMissionLoaded = state.data.missions.some(
+        (mission) => mission.identifier === state.selectedMissionIdentifier
+      );
+      if (!isSelectedMissionLoaded) {
+        state.selectedMissionIdentifier = action.payload.missions[0].identifier;
path: src/redux/missions/missionsSlice.ts
user: login: WeirdRubberDuck
body: What happens if there are no missions... ?  E.g. if all the mission assets were deinitialized
created_at: 2025-03-05T15:47:17Z
author_association: COLLABORATOR
diff_hunk: @@ -39,3 +42,31 @@ export function useJumpToTime(fadeTime: number = 1): ReturnType {
     }
   };
 }
+
+export function useSelectedMission(): {
+  mission: Phase | undefined;
+  hasMission: boolean;
+} {
+  const { isInitialized, data, selectedMissionIdentifier } = useAppSelector(
+    (state) => state.missions
+  );
+
+  let mission: Phase | undefined = undefined;
+
+  // If a new mission is added, the missions order might have changed so we try to find
+  // the last one viewed
+  if (isInitialized && selectedMissionIdentifier) {
+    const selectedMission = data.missions.find(
path: src/panels/MissionsPanel/hooks.ts
user: login: WeirdRubberDuck
body: This speaks to using a map instead, IMO. I would rather add some complexity in the redux store than have to search the list of missions. 

And it would be nice to avoid having to use zero-indexing to get the mission if there is only one, but maybe we can't get away from that :) 
created_at: 2025-03-05T16:01:02Z
author_association: COLLABORATOR
diff_hunk: @@ -39,3 +42,31 @@ export function useJumpToTime(fadeTime: number = 1): ReturnType {
     }
   };
 }
+
+export function useSelectedMission(): {
+  mission: Phase | undefined;
+  hasMission: boolean;
+} {
+  const { isInitialized, data, selectedMissionIdentifier } = useAppSelector(
+    (state) => state.missions
+  );
+
+  let mission: Phase | undefined = undefined;
+
+  // If a new mission is added, the missions order might have changed so we try to find
+  // the last one viewed
+  if (isInitialized && selectedMissionIdentifier) {
+    const selectedMission = data.missions.find(
+      (mission) => mission.identifier === selectedMissionIdentifier
+    );
+    if (selectedMission) {
+      mission = selectedMission;
+    } else {
+      mission = data.missions.at(0);
+    }
path: src/panels/MissionsPanel/hooks.ts
user: login: WeirdRubberDuck
body: ```suggestion
    mission = selectedMission ?? data.missions.at(0);
```
?
created_at: 2025-03-05T16:02:04Z
author_association: COLLABORATOR
diff_hunk: @@ -39,3 +42,31 @@ export function useJumpToTime(fadeTime: number = 1): ReturnType {
     }
   };
 }
+
+export function useSelectedMission(): {
+  mission: Phase | undefined;
+  hasMission: boolean;
+} {
+  const { isInitialized, data, selectedMissionIdentifier } = useAppSelector(
+    (state) => state.missions
+  );
+
+  let mission: Phase | undefined = undefined;
+
+  // If a new mission is added, the missions order might have changed so we try to find
+  // the last one viewed
+  if (isInitialized && selectedMissionIdentifier) {
+    const selectedMission = data.missions.find(
+      (mission) => mission.identifier === selectedMissionIdentifier
+    );
+    if (selectedMission) {
+      mission = selectedMission;
+    } else {
+      mission = data.missions.at(0);
+    }
path: src/panels/MissionsPanel/hooks.ts
user: login: WeirdRubberDuck
body: Can we end up here if there are no missions?  Getting and index zero seems a bit dangerous if there's no check against if `data.missions` have enough values 
created_at: 2025-03-05T16:03:12Z
author_association: COLLABORATOR
diff_hunk: @@ -1,15 +1,33 @@
-import { Stack, Text, ThemeIcon, Title } from '@mantine/core';
+import { Box, Select, Stack, Text, ThemeIcon, Title } from '@mantine/core';
 
+import { useOpenSpaceApi } from '@/api/hooks';
 import { RocketLaunchIcon } from '@/icons/icons';
-import { useAppSelector } from '@/redux/hooks';
+import { useAppDispatch, useAppSelector } from '@/redux/hooks';
+import { setSelectedMission } from '@/redux/missions/missionsSlice';
 
 import { MissionContent } from './MissionContent';
 
 export function MissionsPanel() {
-  const hasMission = useAppSelector((state) => state.missions.isInitialized);
-  const mission = useAppSelector((state) => state.missions.data.missions[0]);
+  const { isInitialized, missions, selectedMissionIdentifier } = useAppSelector(
+    (state) => state.missions
+  );
 
-  if (!hasMission) {
+  const luaApi = useOpenSpaceApi();
+  const dispatch = useAppDispatch();
+
+  // Grab the mission last viewed or if it does not exist anymore, take the first one
+  const mission = missions[selectedMissionIdentifier] ?? Object.values(missions)[0];
+  const hasMission = isInitialized && mission !== undefined;
+
+  function onMissionSelected(identifier: string | null) {
+    if (!identifier) {
+      return;
+    }
+    dispatch(setSelectedMission(identifier));
+    luaApi?.setCurrentMission(identifier);
+  }
+
+  if (!hasMission || !mission) {
path: src/panels/MissionsPanel/MissionsPanel.tsx
user: login: ylvaselling
body: When there are no missions, `Object.values(missions)` creates an empty array, and we then take the first element in that empty array (going out of bounds). This is when mission is undefined on the next line. Although we don't get a warning or error for this, going out of bounds on an array should be avoided, so how about something like this instead? (with next comment)

```suggestion
  const luaApi = useOpenSpaceApi();
  const dispatch = useAppDispatch();

  const hasMission = isInitialized && Object.values(missions).length > 0;

  function onMissionSelected(identifier: string | null) {
    if (!identifier) {
      return;
    }
    dispatch(setSelectedMission(identifier));
    luaApi?.setCurrentMission(identifier);
  }

  if (!hasMission) {
```
created_at: 2025-03-10T07:06:38Z
author_association: COLLABORATOR
diff_hunk: @@ -23,5 +41,22 @@ export function MissionsPanel() {
     );
   }
 
-  return <MissionContent missionOverview={mission} />;
+  return (
+    <Box>
+      {Object.keys(missions).length > 1 && (
+        <Select
+          label={'Selected mission'}
+          placeholder={'Select a mission'}
+          data={Object.entries(missions).map(([identifier, mission]) => {
+            return { value: identifier, label: mission.name };
+          })}
+          value={selectedMissionIdentifier}
+          onChange={onMissionSelected}
+          my={'xs'}
+          allowDeselect={false}
+        />
+      )}
+      <MissionContent missionOverview={mission} />
+    </Box>
+  );
path: src/panels/MissionsPanel/MissionsPanel.tsx
user: login: ylvaselling
body: ```suggestion
const mission = missions[selectedMissionIdentifier] ?? Object.values(missions)[0];
  return (
    <Box>
      {Object.keys(missions).length > 1 && (
        <Select
          label={'Selected mission'}
          placeholder={'Select a mission'}
          data={Object.entries(missions).map(([identifier, mission]) => {
            return { value: identifier, label: mission.name };
          })}
          value={selectedMissionIdentifier}
          onChange={onMissionSelected}
          my={'xs'}
          allowDeselect={false}
        />
      )}
      <MissionContent missionOverview={mission} />
    </Box>
  );
```
created_at: 2025-03-10T07:07:23Z
author_association: COLLABORATOR
diff_hunk: @@ -42,6 +44,12 @@ export const setupEventsSubscription = createAsyncThunk(
         case 'ActionRemoved':
           thunkAPI.dispatch(removeAction(data.Uri));
           break;
+        case 'MissionAdded':
+          thunkAPI.dispatch(refreshMissions());
+          break;
+        case 'MissionRemoved':
+          thunkAPI.dispatch(refreshMissions());
+          break;
path: src/redux/events/eventsMiddleware.ts
user: login: ylvaselling
body: These two cases could be one

```suggestion
case: "MissionAdded":
case: "MissionRemoved":
...
```
created_at: 2025-03-10T07:09:27Z
author_association: COLLABORATOR

PR comments:
user: login: ylvaselling
created_at: 2025-03-04T07:29:35Z
body: Great work! 🎉 

Do we want to show the drop down at all times like in the geo position panel? It looks a bit jumpy when the new mission is added. Maybe the drop down menu could serve as the title and we could get rid of the title? Also there is quite a lot of padding on top of the menu. and we should unify the text above the dropdown with how it looks in the geo panel.
author_association: COLLABORATOR
user: login: ylvaselling
created_at: 2025-03-04T07:39:23Z
body: I saw that there was no way to comment on the code in the engine so jotting down some notes here:

* Should the identifier be the name - rename "identifier" to "name"? I think we have special rules for identifiers (such as no spaces etc). Here you could also create an object with keys instead of the array as suggested above.
* In `event.cpp` should we set the "MissionName" variable to just "Name"? I think Mission is implied already in the event
author_association: COLLABORATOR
user: login: engbergandreas
created_at: 2025-03-06T16:23:27Z
body: 
>     * Should the identifier be the name - rename "identifier" to "name"? I think we have special rules for identifiers (such as no spaces etc). Here you could also create an object with keys instead of the array as suggested above

So this is kind of weird, but the name that we get is in fact the identifier, see `MissionManager::loadMission`. So it should probably be changed to  `for (auto const& [identifier, mission] : missions) .. ` instead? Also, the phase already reserves a key "name" that is the readable name of the mission. 

Changing the topic to send it as a map as suggested though.



author_association: MEMBER
user: login: WeirdRubberDuck
created_at: 2025-03-07T15:12:08Z
body: Nice with the change to map 👍 
author_association: COLLABORATOR