Merge branch 'main' from workbench2.git
authorTom Clegg <tom@curii.com>
Mon, 6 Nov 2023 17:14:51 +0000 (12:14 -0500)
committerTom Clegg <tom@curii.com>
Mon, 6 Nov 2023 17:14:51 +0000 (12:14 -0500)
refs #18874

Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@curii.com>

14 files changed:
1  2 
services/workbench2/cypress/integration/page-not-found.spec.js
services/workbench2/src/components/collection-panel-files/collection-panel-files.tsx
services/workbench2/src/index.tsx
services/workbench2/src/services/collection-service/collection-service.ts
services/workbench2/src/store/process-logs-panel/process-logs-panel-actions.ts
services/workbench2/src/store/process-panel/process-panel-actions.ts
services/workbench2/src/store/project-panel/project-panel-middleware-service.ts
services/workbench2/src/store/trash/trash-actions.ts
services/workbench2/src/store/workbench/workbench-actions.ts
services/workbench2/src/views/collection-panel/collection-panel.tsx
services/workbench2/src/views/not-found-panel/not-found-panel.tsx
services/workbench2/src/views/process-panel/process-panel-root.tsx
services/workbench2/src/views/project-panel/project-panel.tsx
services/workbench2/src/views/workflow-panel/registered-workflow-panel.tsx

index 4df4135c878ed63e4ef667eda50697985a31201d,6eab27c827dc3b1d0ffbc6b4cd22ce0f79e9b6fb..6eab27c827dc3b1d0ffbc6b4cd22ce0f79e9b6fb
@@@ -45,8 -45,7 +45,7 @@@ describe('Page not found tests', functi
              cy.goToPath(path);
  
              // then
-             cy.get('[data-cy=not-found-page]').should('not.exist');
-             cy.get('[data-cy=not-found-content]').should('exist');
+             cy.get('[data-cy=not-found-view]').should('exist');
          });
      });
- })
+ })
index 83de48dec8fb40552e7d5e3a970b27d087ef6983,f1e50e0f0bf7d2f9c4699265e19d515954a9d7cc..f1e50e0f0bf7d2f9c4699265e19d515954a9d7cc
@@@ -311,6 -311,8 +311,8 @@@ export const CollectionPanelFiles = wit
                              return { ...next, ...prev };
                          }, {});
                      setPathData(state => ({ ...state, ...newState }));
+                 }, () => {
+                     // Nothing to do
                  })
                  .finally(() => {
                      setIsLoading(false);
index a3f6c1ee79f33ede1c7a543bc5a599d5292d24f8,ede257dc5d10dc89fdbeb94718bcee8d68cc10ae..ede257dc5d10dc89fdbeb94718bcee8d68cc10ae
@@@ -64,7 -64,6 +64,6 @@@ import 
      runningProcessResourceAdminActionSet,
      readOnlyProcessResourceActionSet,
  } from "views-components/context-menu/action-sets/process-resource-action-set";
- import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
  import { trashedCollectionActionSet } from "views-components/context-menu/action-sets/trashed-collection-action-set";
  import { setBuildInfo } from "store/app-info/app-info-actions";
  import { getBuildInfo } from "common/app-info";
@@@ -89,8 -88,6 +88,6 @@@ import 
  } from "views-components/context-menu/action-sets/project-admin-action-set";
  import { permissionEditActionSet } from "views-components/context-menu/action-sets/permission-edit-action-set";
  import { workflowActionSet, readOnlyWorkflowActionSet } from "views-components/context-menu/action-sets/workflow-action-set";
- import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
- import { openNotFoundDialog } from "./store/not-found-panel/not-found-panel-action";
  import { storeRedirects } from "./common/redirect-to";
  import { searchResultsActionSet } from "views-components/context-menu/action-sets/search-results-action-set";
  
@@@ -153,25 -150,13 +150,13 @@@ fetchConfig().then(({ config, apiHost }
  
      const services = createServices(config, {
          progressFn: (id, working) => {
-             //store.dispatch(progressIndicatorActions.TOGGLE_WORKING({ id, working }));
          },
          errorFn: (id, error, showSnackBar: boolean) => {
              if (showSnackBar) {
                  console.error("Backend error:", error);
-                 if (error.status === 404) {
-                     store.dispatch(openNotFoundDialog());
-                 } else if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) {
+                 if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) {
                      // Catch auth errors when navigating and redirect to login preserving url location
                      store.dispatch(logout(false, true));
-                 } else {
-                     store.dispatch(
-                         snackbarActions.OPEN_SNACKBAR({
-                             message: `${error.errors ? error.errors[0] : error.message}`,
-                             kind: SnackbarKind.ERROR,
-                             hideDuration: 8000,
-                         })
-                     );
                  }
              }
          },
index de8f258708dc1e94d278a9324a8c68e3f2a9aa6a,e50e5ed35026403c6332865d6b897c32a01f5605..e50e5ed35026403c6332865d6b897c32a01f5605
@@@ -53,11 -53,14 +53,14 @@@ export class CollectionService extends 
      }
  
      async files(uuid: string) {
-         const request = await this.keepWebdavClient.propfind(`c=${uuid}`);
-         if (request.responseXML != null) {
-             return extractFilesData(request.responseXML);
+         try {
+             const request = await this.keepWebdavClient.propfind(`c=${uuid}`);
+             if (request.responseXML != null) {
+                 return extractFilesData(request.responseXML);
+             }
+         } catch (e) {
+             return Promise.reject(e);
          }
          return Promise.reject();
      }
  
index 87a2fa12aaddc1d6d980908583dfda366c0f1ab4,4e52431eebadc9a05b240ee530cd29e3390569e4..4e52431eebadc9a05b240ee530cd29e3390569e4
@@@ -13,7 -13,7 +13,7 @@@ import { Process, getProcess } from 'st
  import { navigateTo } from 'store/navigation/navigation-action';
  import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
  import { CollectionFile, CollectionFileType } from "models/collection-file";
- import { ContainerRequestResource } from "models/container-request";
+ import { ContainerRequestResource, ContainerRequestState } from "models/container-request";
  
  const SNIPLINE = `================ ✀ ================ ✀ ========= Some log(s) were skipped ========= ✀ ================ ✀ ================`;
  const LOG_TIMESTAMP_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9}Z/;
@@@ -40,15 -40,16 +40,16 @@@ export const setProcessLogsPanelFilter 
  
  export const initProcessLogsPanel = (processUuid: string) =>
      async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => {
+         let process: Process | undefined;
          try {
              dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
-             const process = getProcess(processUuid)(getState().resources);
+             process = getProcess(processUuid)(getState().resources);
              if (process?.containerRequest?.uuid) {
                  // Get log file size info
                  const logFiles = await loadContainerLogFileList(process.containerRequest, logService);
  
                  // Populate lastbyte 0 for each file
-                 const filesWithProgress = logFiles.map((file) => ({file, lastByte: 0}));
+                 const filesWithProgress = logFiles.map((file) => ({ file, lastByte: 0 }));
  
                  // Fetch array of LogFragments
                  const logLines = await loadContainerLogFileContents(filesWithProgress, logService, process);
                  const initialState = createInitialLogPanelState(logFiles, logLines);
                  dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
              }
-         } catch(e) {
+         } catch (e) {
              // On error, populate empty state to allow polling to start
              const initialState = createInitialLogPanelState([], []);
              dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
              // Only show toast on errors other than 404 since 404 is expected when logs do not exist yet
              if (e.status !== 404) {
-                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not load process logs', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Error loading process logs', hideDuration: 4000, kind: SnackbarKind.ERROR }));
+             }
+             if (e.status === 404 && process?.containerRequest.state === ContainerRequestState.FINAL) {
+                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING }));
              }
          }
      };
@@@ -88,7 -92,7 +92,7 @@@ export const pollProcessLogs = (process
                      const isChanged = !isNew && currentStateLogLastByte < updatedFile.size;
  
                      if (isNew || isChanged) {
-                         return acc.concat({file: updatedFile, lastByte: currentStateLogLastByte});
+                         return acc.concat({ file: updatedFile, lastByte: currentStateLogLastByte });
                      } else {
                          return acc;
                      }
@@@ -132,17 -136,17 +136,17 @@@ const loadContainerLogFileList = async 
   * @returns LogFragment[] containing a single LogFragment corresponding to each input file
   */
  const loadContainerLogFileContents = async (logFilesWithProgress: FileWithProgress[], logService: LogService, process: Process) => (
-     (await Promise.allSettled(logFilesWithProgress.filter(({file}) => file.size > 0).map(({file, lastByte}) => {
+     (await Promise.allSettled(logFilesWithProgress.filter(({ file }) => file.size > 0).map(({ file, lastByte }) => {
          const requestSize = file.size - lastByte;
          if (requestSize > maxLogFetchSize) {
              const chunkSize = Math.floor(maxLogFetchSize / 2);
-             const firstChunkEnd = lastByte+chunkSize-1;
+             const firstChunkEnd = lastByte + chunkSize - 1;
              return Promise.all([
                  logService.getLogFileContents(process.containerRequest, file, lastByte, firstChunkEnd),
-                 logService.getLogFileContents(process.containerRequest, file, file.size-chunkSize, file.size-1)
+                 logService.getLogFileContents(process.containerRequest, file, file.size - chunkSize, file.size - 1)
              ] as Promise<(LogFragment)>[]);
          } else {
-             return Promise.all([logService.getLogFileContents(process.containerRequest, file, lastByte, file.size-1)]);
+             return Promise.all([logService.getLogFileContents(process.containerRequest, file, lastByte, file.size - 1)]);
          }
      })).then((res) => {
          if (res.length && res.every(promiseResult => (promiseResult.status === 'rejected'))) {
              //   error if every request failed
              const error = res.find(
                  (promiseResult): promiseResult is PromiseRejectedResult => promiseResult.status === 'rejected'
-               )?.reason;
+             )?.reason;
              return Promise.reject(error);
          }
          return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<LogFragment[]> => (
              //   (prevent incorrect snipline generation or an un-resumable situation)
              !!promiseResult.value.every(logFragment => logFragment.contents.length)
          )).map(one => one.value)
-     })).map((logResponseSet)=> {
+     })).map((logResponseSet) => {
          // For any multi fragment response set, modify the last line of non-final chunks to include a line break and snip line
          //   Don't add snip line as a separate line so that sorting won't reorder it
          for (let i = 1; i < logResponseSet.length; i++) {
-             const fragment = logResponseSet[i-1];
-             const lastLineIndex = fragment.contents.length-1;
+             const fragment = logResponseSet[i - 1];
+             const lastLineIndex = fragment.contents.length - 1;
              const lastLineContents = fragment.contents[lastLineIndex];
              const newLastLine = `${lastLineContents}\n${SNIPLINE}`;
  
-             logResponseSet[i-1].contents[lastLineIndex] = newLastLine;
+             logResponseSet[i - 1].contents[lastLineIndex] = newLastLine;
          }
  
          // Merge LogFragment Array (representing multiple log line arrays) into single LogLine[] / LogFragment
      })
  );
  
- const createInitialLogPanelState = (logFiles: CollectionFile[], logFragments: LogFragment[]): {filters: string[], logs: ProcessLogs} => {
+ const createInitialLogPanelState = (logFiles: CollectionFile[], logFragments: LogFragment[]): { filters: string[], logs: ProcessLogs } => {
      const logs = groupLogs(logFiles, logFragments);
      const filters = Object.keys(logs);
      return { filters, logs };
@@@ -201,12 -205,12 +205,12 @@@ const groupLogs = (logFiles: Collection
  
      const groupedLogs = logFragments.reduce((grouped, fragment) => ({
          ...grouped,
-         [fragment.logType as string]: {lastByte: fetchLastByteNumber(logFiles, fragment.logType), contents: fragment.contents}
+         [fragment.logType as string]: { lastByte: fetchLastByteNumber(logFiles, fragment.logType), contents: fragment.contents }
      }), {});
  
      return {
-         [MAIN_FILTER_TYPE]: {lastByte: undefined, contents: mainLogs},
-         [ALL_FILTER_TYPE]: {lastByte: undefined, contents: allLogs},
+         [MAIN_FILTER_TYPE]: { lastByte: undefined, contents: mainLogs },
+         [ALL_FILTER_TYPE]: { lastByte: undefined, contents: allLogs },
          ...groupedLogs,
      }
  };
@@@ -233,9 -237,9 +237,9 @@@ const mergeMultilineLoglines = (logFrag
                      // Partial line without timestamp detected
                      if (i > 0) {
                          // If not first line, copy line to previous line
-                         const previousLineContents = fragmentCopy.contents[i-1];
+                         const previousLineContents = fragmentCopy.contents[i - 1];
                          const newPreviousLineContents = `${previousLineContents}\n${lineContents}`;
-                         fragmentCopy.contents[i-1] = newPreviousLineContents;
+                         fragmentCopy.contents[i - 1] = newPreviousLineContents;
                      }
                      // Delete the current line and prevent iterating
                      fragmentCopy.contents.splice(i, 1);
@@@ -283,7 -287,7 +287,7 @@@ export const navigateToLogCollection = 
              await services.collectionService.get(uuid);
              dispatch<any>(navigateTo(uuid));
          } catch {
-             dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not request collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+             dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING }));
          }
      };
  
index 03e36aac98fb837752c932df4a72c913cea5cc6c,81f8dd6ba0dc456f980ffaec17bf25000c5afa3d..81f8dd6ba0dc456f980ffaec17bf25000c5afa3d
@@@ -8,10 -8,9 +8,9 @@@ import { Dispatch } from "redux"
  import { ProcessStatus } from "store/processes/process";
  import { RootState } from "store/store";
  import { ServiceRepository } from "services/services";
- import { navigateTo, navigateToWorkflows } from "store/navigation/navigation-action";
+ import { navigateTo } from "store/navigation/navigation-action";
  import { snackbarActions } from "store/snackbar/snackbar-actions";
  import { SnackbarKind } from "../snackbar/snackbar-actions";
- import { showWorkflowDetails } from "store/workflow-panel/workflow-panel-actions";
  import { loadSubprocessPanel, subprocessPanelActions } from "../subprocess-panel/subprocess-panel-actions";
  import { initProcessLogsPanel, processLogsPanelActions } from "store/process-logs-panel/process-logs-panel-actions";
  import { CollectionFile } from "models/collection-file";
@@@ -59,7 -58,7 +58,7 @@@ export const navigateToOutput = (uuid: 
          await services.collectionService.get(uuid);
          dispatch<any>(navigateTo(uuid));
      } catch {
-         dispatch(snackbarActions.OPEN_SNACKBAR({ message: "This collection does not exists!", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+         dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Output collection was trashed or deleted.", hideDuration: 4000, kind: SnackbarKind.WARNING }));
      }
  };
  
@@@ -159,8 -158,7 +158,7 @@@ export const updateOutputParams = () =
  };
  
  export const openWorkflow = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-     dispatch<any>(navigateToWorkflows);
-     dispatch<any>(showWorkflowDetails(uuid));
+     dispatch<any>(navigateTo(uuid));
  };
  
  export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FILTERS([
index c0c0cd1873f262546b694bf2132a5e353f12cb14,b72058d56e81dd0ed4a42dce663357ffcaea4dd9..b72058d56e81dd0ed4a42dce663357ffcaea4dd9
@@@ -69,7 -69,12 +69,12 @@@ export class ProjectPanelMiddlewareServ
                          rowsPerPage: dataExplorer.rowsPerPage,
                      })
                  );
-                 api.dispatch(couldNotFetchProjectContents());
+                 if (e.status === 404) {
+                     // It'll just show up as not found
+                 }
+                 else {
+                     api.dispatch(couldNotFetchProjectContents());
+                 }
              } finally {
                  if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
              }
index 884293a90e57e4e914486fe864b9bb4ea9240473,62b669220e68e2e6d3089f878f7fe4e73f29b962..62b669220e68e2e6d3089f878f7fe4e73f29b962
@@@ -9,104 -9,112 +9,112 @@@ import { snackbarActions, SnackbarKind 
  import { trashPanelActions } from "store/trash-panel/trash-panel-action";
  import { activateSidePanelTreeItem, loadSidePanelTreeProjects } from "store/side-panel-tree/side-panel-tree-actions";
  import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+ import { sharedWithMePanelActions } from "store/shared-with-me-panel/shared-with-me-panel-actions";
  import { ResourceKind } from "models/resource";
  import { navigateTo, navigateToTrash } from "store/navigation/navigation-action";
- import { matchCollectionRoute } from "routes/routes";
+ import { matchCollectionRoute, matchSharedWithMeRoute } from "routes/routes";
  
  export const toggleProjectTrashed =
      (uuid: string, ownerUuid: string, isTrashed: boolean, isMulti: boolean) =>
-     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
-         let errorMessage = "";
-         let successMessage = "";
-         let untrashedResource;
-         try {
-             if (isTrashed) {
-                 errorMessage = "Could not restore project from trash";
-                 successMessage = "Restored project from trash";
-                 untrashedResource = await services.groupsService.untrash(uuid);
-                 dispatch<any>(isMulti || !untrashedResource ? navigateToTrash : navigateTo(uuid));
-                 dispatch<any>(activateSidePanelTreeItem(uuid));
-             } else {
-                 errorMessage = "Could not move project to trash";
-                 successMessage = "Added project to trash";
-                 await services.groupsService.trash(uuid);
-                 dispatch<any>(loadSidePanelTreeProjects(ownerUuid));
-                 dispatch<any>(navigateTo(ownerUuid));
-             }
-             if (untrashedResource) {
-                 dispatch(
-                     snackbarActions.OPEN_SNACKBAR({
-                         message: successMessage,
-                         hideDuration: 2000,
-                         kind: SnackbarKind.SUCCESS,
-                     })
-                 );
-             }
-         } catch (e) {
-             if (e.status === 422) {
-                 dispatch(
-                     snackbarActions.OPEN_SNACKBAR({
-                         message: "Could not restore project from trash: Duplicate name at destination",
-                         kind: SnackbarKind.ERROR,
-                     })
-                 );
-             } else {
-                 dispatch(
-                     snackbarActions.OPEN_SNACKBAR({
-                         message: errorMessage,
-                         kind: SnackbarKind.ERROR,
-                     })
-                 );
+         async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+             let errorMessage = "";
+             let successMessage = "";
+             let untrashedResource;
+             try {
+                 if (isTrashed) {
+                     errorMessage = "Could not restore project from trash";
+                     successMessage = "Restored project from trash";
+                     untrashedResource = await services.groupsService.untrash(uuid);
+                     dispatch<any>(isMulti || !untrashedResource ? navigateToTrash : navigateTo(uuid));
+                     dispatch<any>(activateSidePanelTreeItem(uuid));
+                 } else {
+                     errorMessage = "Could not move project to trash";
+                     successMessage = "Added project to trash";
+                     await services.groupsService.trash(uuid);
+                     dispatch<any>(loadSidePanelTreeProjects(ownerUuid));
+                     const { location } = getState().router;
+                     if (matchSharedWithMeRoute(location ? location.pathname : "")) {
+                         dispatch(sharedWithMePanelActions.REQUEST_ITEMS());
+                     }
+                     else {
+                         dispatch<any>(navigateTo(ownerUuid));
+                     }
+                 }
+                 if (untrashedResource) {
+                     dispatch(
+                         snackbarActions.OPEN_SNACKBAR({
+                             message: successMessage,
+                             hideDuration: 2000,
+                             kind: SnackbarKind.SUCCESS,
+                         })
+                     );
+                 }
+             } catch (e) {
+                 if (e.status === 422) {
+                     dispatch(
+                         snackbarActions.OPEN_SNACKBAR({
+                             message: "Could not restore project from trash: Duplicate name at destination",
+                             kind: SnackbarKind.ERROR,
+                         })
+                     );
+                 } else {
+                     dispatch(
+                         snackbarActions.OPEN_SNACKBAR({
+                             message: errorMessage,
+                             kind: SnackbarKind.ERROR,
+                         })
+                     );
+                 }
              }
-         }
-     };
+         };
  
  export const toggleCollectionTrashed =
      (uuid: string, isTrashed: boolean) =>
-     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
-         let errorMessage = "";
-         let successMessage = "";
-         try {
-             if (isTrashed) {
-                 const { location } = getState().router;
-                 errorMessage = "Could not restore collection from trash";
-                 successMessage = "Restored from trash";
-                 await services.collectionService.untrash(uuid);
-                 if (matchCollectionRoute(location ? location.pathname : "")) {
-                     dispatch(navigateToTrash);
+         async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+             let errorMessage = "";
+             let successMessage = "";
+             try {
+                 if (isTrashed) {
+                     const { location } = getState().router;
+                     errorMessage = "Could not restore collection from trash";
+                     successMessage = "Restored from trash";
+                     await services.collectionService.untrash(uuid);
+                     if (matchCollectionRoute(location ? location.pathname : "")) {
+                         dispatch(navigateToTrash);
+                     }
+                     dispatch(trashPanelActions.REQUEST_ITEMS());
+                 } else {
+                     errorMessage = "Could not move collection to trash";
+                     successMessage = "Added to trash";
+                     await services.collectionService.trash(uuid);
+                     dispatch(projectPanelActions.REQUEST_ITEMS());
                  }
-                 dispatch(trashPanelActions.REQUEST_ITEMS());
-             } else {
-                 errorMessage = "Could not move collection to trash";
-                 successMessage = "Added to trash";
-                 await services.collectionService.trash(uuid);
-                 dispatch(projectPanelActions.REQUEST_ITEMS());
-             }
-             dispatch(
-                 snackbarActions.OPEN_SNACKBAR({
-                     message: successMessage,
-                     hideDuration: 2000,
-                     kind: SnackbarKind.SUCCESS,
-                 })
-             );
-         } catch (e) {
-             if (e.status === 422) {
                  dispatch(
                      snackbarActions.OPEN_SNACKBAR({
-                         message: "Could not restore collection from trash: Duplicate name at destination",
-                         kind: SnackbarKind.ERROR,
-                     })
-                 );
-             } else {
-                 dispatch(
-                     snackbarActions.OPEN_SNACKBAR({
-                         message: errorMessage,
-                         kind: SnackbarKind.ERROR,
+                         message: successMessage,
+                         hideDuration: 2000,
+                         kind: SnackbarKind.SUCCESS,
                      })
                  );
+             } catch (e) {
+                 if (e.status === 422) {
+                     dispatch(
+                         snackbarActions.OPEN_SNACKBAR({
+                             message: "Could not restore collection from trash: Duplicate name at destination",
+                             kind: SnackbarKind.ERROR,
+                         })
+                     );
+                 } else {
+                     dispatch(
+                         snackbarActions.OPEN_SNACKBAR({
+                             message: errorMessage,
+                             kind: SnackbarKind.ERROR,
+                         })
+                     );
+                 }
              }
-         }
-     };
+         };
  
  export const toggleTrashed = (kind: ResourceKind, uuid: string, ownerUuid: string, isTrashed: boolean) => (dispatch: Dispatch) => {
      if (kind === ResourceKind.PROJECT) {
index b03400d5ae28c7abc55b86ac5e0f01e99668e95a,f2dae2c524c3221daac423cfdca622feb0d8efb2..f2dae2c524c3221daac423cfdca622feb0d8efb2
@@@ -109,6 -109,12 +109,12 @@@ export const isWorkbenchLoading = (stat
  export const handleFirstTimeLoad = (action: any) => async (dispatch: Dispatch<any>, getState: () => RootState) => {
      try {
          await dispatch(action);
+     } catch (e) {
+         snackbarActions.OPEN_SNACKBAR({
+             message: "Error " + e,
+             hideDuration: 8000,
+             kind: SnackbarKind.WARNING,
+         })
      } finally {
          if (isWorkbenchLoading(getState())) {
              dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
index 8cf19c03fef183a24d93f52f31ea1a26af822b14,d93d6e9258673e5dc49979a6262f9a9bef47570d..d93d6e9258673e5dc49979a6262f9a9bef47570d
@@@ -37,6 -37,7 +37,7 @@@ import { Link as ButtonLink } from '@ma
  import { ResourceWithName, ResponsiblePerson } from 'views-components/data-explorer/renderers';
  import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
  import { resourceIsFrozen } from 'common/frozen-resources';
+ import { NotFoundView } from 'views/not-found-panel/not-found-panel';
  
  type CssRules = 'root'
      | 'button'
@@@ -229,7 -230,11 +230,11 @@@ export const CollectionPanel = withStyl
                              </Card>
                          </MPVPanelContent>
                      </MPVContainer >
-                     : null;
+                     : <NotFoundView
+                         icon={CollectionIcon}
+                         messages={["Collection not found"]}
+                     />
+                     ;
              }
  
              handleContextMenu = (event: React.MouseEvent<any>) => {
index 148c331e2971b91ee826ca92a9a1f57b2ba8f312,f54c00c32a2f2856636b263bb19f75b8326d6666..f54c00c32a2f2856636b263bb19f75b8326d6666
@@@ -3,8 -3,12 +3,12 @@@
  // SPDX-License-Identifier: AGPL-3.0
  
  import { RootState } from 'store/store';
+ import React from 'react';
  import { connect } from 'react-redux';
  import { NotFoundPanelRoot, NotFoundPanelRootDataProps } from 'views/not-found-panel/not-found-panel-root';
+ import { Grid } from '@material-ui/core';
+ import { DefaultView } from "components/default-view/default-view";
+ import { IconType } from 'components/icon/icon';
  
  const mapStateToProps = (state: RootState): NotFoundPanelRootDataProps => {
      return {
@@@ -17,3 -21,26 +21,26 @@@ const mapDispatchToProps = null
  
  export const NotFoundPanel = connect(mapStateToProps, mapDispatchToProps)
      (NotFoundPanelRoot) as any;
+ export interface NotFoundViewDataProps {
+     messages: string[];
+     icon?: IconType;
+ }
+ // TODO: optionally pass in the UUID and check if the
+ // reason the item is not found is because
+ // it or a parent project is actually in the trash.
+ // If so, offer to untrash the item or the parent project.
+ export const NotFoundView =
+     ({ messages, icon: Icon }: NotFoundViewDataProps) =>
+         <Grid
+             container
+             alignItems="center"
+             justify="center"
+             style={{ minHeight: "100%" }}
+             data-cy="not-found-view">
+             <DefaultView
+                 icon={Icon}
+                 messages={messages}
+             />
+         </Grid>;
index d019d1418fcf0c8841a94989e1211daffaf864b6,7a24089901333e5195af66081bf14bb8369e0035..7a24089901333e5195af66081bf14bb8369e0035
@@@ -3,8 -3,7 +3,7 @@@
  // SPDX-License-Identifier: AGPL-3.0
  
  import React from "react";
- import { Grid, StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core";
- import { DefaultView } from "components/default-view/default-view";
+ import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core";
  import { ProcessIcon } from "components/icon/icon";
  import { Process } from "store/processes/process";
  import { SubprocessPanel } from "views/subprocess-panel/subprocess-panel";
@@@ -24,6 -23,7 +23,7 @@@ import { AuthState } from "store/auth/a
  import { ProcessCmdCard } from "./process-cmd-card";
  import { ContainerRequestResource } from "models/container-request";
  import { OutputDetails, NodeInstanceType } from "store/process-panel/process-panel";
+ import { NotFoundView } from 'views/not-found-panel/not-found-panel';
  
  type CssRules = "root";
  
@@@ -209,16 -209,10 +209,10 @@@ export const ProcessPanelRoot = withSty
                  </MPVPanelContent>
              </MPVContainer>
          ) : (
-             <Grid
-                 container
-                 alignItems="center"
-                 justify="center"
-                 style={{ minHeight: "100%" }}>
-                 <DefaultView
-                     icon={ProcessIcon}
-                     messages={["Process not found"]}
-                 />
-             </Grid>
+             <NotFoundView
+                 icon={ProcessIcon}
+                 messages={["Process not found"]}
+             />
          );
      }
  );
index 4c94ab8d2dd05f87fd970f4c9b5a8369f4729cef,2cc751bffd6e78526b38ea07e8d0a5ca4d0683f2..2cc751bffd6e78526b38ea07e8d0a5ca4d0683f2
@@@ -51,6 -51,7 +51,7 @@@ import { GroupClass, GroupResource } fr
  import { CollectionResource } from 'models/collection';
  import { resourceIsFrozen } from 'common/frozen-resources';
  import { ProjectResource } from 'models/project';
+ import { NotFoundView } from 'views/not-found-panel/not-found-panel';
  
  type CssRules = 'root' | 'button';
  
@@@ -238,6 -239,7 +239,7 @@@ const DEFAULT_VIEW_MESSAGES = ['Your pr
  interface ProjectPanelDataProps {
      currentItemId: string;
      resources: ResourcesState;
+     project: GroupResource;
      isAdmin: boolean;
      userUuid: string;
      dataExplorerItems: any;
  
  type ProjectPanelProps = ProjectPanelDataProps & DispatchProp & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
  
- export const ProjectPanel = withStyles(styles)(
-     connect((state: RootState) => ({
-         currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties),
+ const mapStateToProps = (state: RootState) => {
+     const currentItemId = getProperty<string>(PROJECT_PANEL_CURRENT_UUID)(state.properties);
+     const project = getResource<GroupResource>(currentItemId || "")(state.resources);
+     return {
+         currentItemId,
+         project,
          resources: state.resources,
          userUuid: state.auth.user!.uuid,
-     }))(
+     };
+ }
+ export const ProjectPanel = withStyles(styles)(
+     connect(mapStateToProps)(
          class extends React.Component<ProjectPanelProps> {
              render() {
                  const { classes } = this.props;
  
-                 return (
+                 return this.props.project ?
                      <div data-cy='project-panel' className={classes.root}>
                          <DataExplorer
                              id={PROJECT_PANEL_ID}
                              defaultViewMessages={DEFAULT_VIEW_MESSAGES}
                          />
                      </div>
-                 );
+                     :
+                     <NotFoundView
+                         icon={ProjectIcon}
+                         messages={["Project not found"]}
+                     />
              }
  
              isCurrentItemChild = (resource: Resource) => {
index 5973efedc8fe0626e10cd13ad5465e36baab79f4,50192e543dbe7f3cf7a3368a9743cd5a278b394d..50192e543dbe7f3cf7a3368a9743cd5a278b394d
@@@ -12,7 -12,7 +12,7 @@@ import 
      Card,
      CardHeader,
      CardContent,
-     IconButton,
+     IconButton
  } from '@material-ui/core';
  import { connect, DispatchProp } from "react-redux";
  import { RouteComponentProps } from 'react-router';
@@@ -26,6 -26,7 +26,7 @@@ import { getResource } from 'store/reso
  import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
  import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
  import { ProcessIOCard, ProcessIOCardType } from 'views/process-panel/process-io-card';
+ import { NotFoundView } from 'views/not-found-panel/not-found-panel';
  
  type CssRules = 'root'
      | 'button'
@@@ -200,7 -201,11 +201,11 @@@ export const RegisteredWorkflowPanel = 
                              </Card>
                          </MPVPanelContent>
                      </MPVContainer>
-                     : null;
+                     :
+                     <NotFoundView
+                         icon={WorkflowIcon}
+                         messages={["Workflow not found"]}
+                     />
              }
  
              handleContextMenu = (event: React.MouseEvent<any>) => {