diff --git a/backend/src/backend/primary/routers/explore.py b/backend/src/backend/primary/routers/explore.py index 74c5cb422..7a0e9561c 100644 --- a/backend/src/backend/primary/routers/explore.py +++ b/backend/src/backend/primary/routers/explore.py @@ -4,34 +4,43 @@ from pydantic import BaseModel from src.services.sumo_access.sumo_explore import SumoExplore +from src.services.sumo_access.iteration_inspector import IterationInspector from src.services.utils.authenticated_user import AuthenticatedUser from src.backend.auth.auth_helper import AuthHelper router = APIRouter() -class Field(BaseModel): +class FieldInfo(BaseModel): field_identifier: str -class Case(BaseModel): +class CaseInfo(BaseModel): uuid: str name: str -class Ensemble(BaseModel): +class EnsembleInfo(BaseModel): name: str + realization_count: int + + +class EnsembleDetails(BaseModel): + name: str + case_name: str + case_uuid: str + realizations: List[int] @router.get("/fields") -def get_fields() -> List[Field]: +def get_fields() -> List[FieldInfo]: """ Get list of fields """ ret_arr = [ - Field(field_identifier="DROGON"), - Field(field_identifier="JOHAN SVERDRUP"), - Field(field_identifier="DUMMY_FIELD"), + FieldInfo(field_identifier="DROGON"), + FieldInfo(field_identifier="JOHAN SVERDRUP"), + FieldInfo(field_identifier="DUMMY_FIELD"), ] return ret_arr @@ -40,7 +49,7 @@ def get_fields() -> List[Field]: def get_cases( authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), field_identifier: str = Query(description="Field identifier"), -) -> List[Case]: +) -> List[CaseInfo]: """Get list of cases for specified field""" print(authenticated_user.get_sumo_access_token()) sumo_discovery = SumoExplore(authenticated_user.get_sumo_access_token()) @@ -51,15 +60,15 @@ def get_cases( # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! # Sumo Explorer + Drogon + SMRY data is still a work in progress! # Present the single DROGON case that we know to be good as the first item, also prefixing it with "GOOD" - ret_arr: List[Case] = [] + ret_arr: List[CaseInfo] = [] if field_identifier == "DROGON": for case_info in case_info_arr: if case_info.uuid == "10f41041-2c17-4374-a735-bb0de62e29dc": - ret_arr.insert(0, Case(uuid=case_info.uuid, name=f"GOOD -- {case_info.name}")) + ret_arr.insert(0, CaseInfo(uuid=case_info.uuid, name=f"GOOD -- {case_info.name}")) else: - ret_arr.append(Case(uuid=case_info.uuid, name=case_info.name)) + ret_arr.append(CaseInfo(uuid=case_info.uuid, name=case_info.name)) else: - ret_arr = [Case(uuid=ci.uuid, name=ci.name) for ci in case_info_arr] + ret_arr = [CaseInfo(uuid=ci.uuid, name=ci.name) for ci in case_info_arr] return ret_arr @@ -68,22 +77,25 @@ def get_cases( def get_ensembles( authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), case_uuid: str = Path(description="Sumo case uuid"), -) -> List[Ensemble]: +) -> List[EnsembleInfo]: """Get list of ensembles for a case""" sumo_discovery = SumoExplore(authenticated_user.get_sumo_access_token()) iteration_info_arr = sumo_discovery.get_iterations(case_uuid=case_uuid) print(iteration_info_arr) - return [Ensemble(name=it.name) for it in iteration_info_arr] + return [EnsembleInfo(name=it.name, realization_count=it.realization_count) for it in iteration_info_arr] -@router.get("/cases/{case_uuid}/ensembles/{ensemble_name}/realizations") -def get_realizations( +@router.get("/cases/{case_uuid}/ensembles/{ensemble_name}") +def get_ensemble_details( authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), case_uuid: str = Path(description="Sumo case uuid"), ensemble_name: str = Path(description="Ensemble name"), -) -> Sequence[int]: - """Get list of realizations for an ensemble""" - sumo_discovery = SumoExplore(authenticated_user.get_sumo_access_token()) - return sumo_discovery.get_realizations(case_uuid=case_uuid, ensemble_name=ensemble_name) +) -> EnsembleDetails: + """Get more detailed information for an ensemble""" + iteration_inspector = IterationInspector(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name) + case_name = iteration_inspector.get_case_name() + realizations = iteration_inspector.get_realizations() + + return EnsembleDetails(name=ensemble_name, case_name=case_name, case_uuid=case_uuid, realizations=realizations) diff --git a/backend/src/backend/primary/routers/parameters/router.py b/backend/src/backend/primary/routers/parameters/router.py index f028e1060..3c948a34f 100644 --- a/backend/src/backend/primary/routers/parameters/router.py +++ b/backend/src/backend/primary/routers/parameters/router.py @@ -21,7 +21,7 @@ @router.get("/parameter_names_and_description/") -async def get_parameter_names_and_description( +def get_parameter_names_and_description( # fmt:off authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), case_uuid: str = Query(description="Sumo case uuid"), @@ -51,7 +51,7 @@ async def get_parameter_names_and_description( @router.get("/parameter/") -async def get_parameter( +def get_parameter( # fmt:off authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), case_uuid: str = Query(description="Sumo case uuid"), @@ -70,7 +70,7 @@ async def get_parameter( @router.get("/is_sensitivity_run/") -async def is_sensitivity_run( +def is_sensitivity_run( # fmt:off authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), case_uuid: str = Query(description="Sumo case uuid"), @@ -84,7 +84,7 @@ async def is_sensitivity_run( @router.get("/sensitivities/") -async def get_sensitivities( +def get_sensitivities( # fmt:off authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), case_uuid: str = Query(description="Sumo case uuid"), diff --git a/backend/src/services/sumo_access/iteration_inspector.py b/backend/src/services/sumo_access/iteration_inspector.py new file mode 100644 index 000000000..9cf47c9a4 --- /dev/null +++ b/backend/src/services/sumo_access/iteration_inspector.py @@ -0,0 +1,28 @@ +from typing import Sequence +from fmu.sumo.explorer.explorer import CaseCollection, Case, SumoClient + +from ._helpers import create_sumo_client_instance + + +class IterationInspector: + """ + Class for inspecting and retrieving iteration (ensemble) information + """ + + def __init__(self, access_token: str, case_uuid: str, iteration_name: str): + sumo_client: SumoClient = create_sumo_client_instance(access_token) + case_collection = CaseCollection(sumo_client).filter(uuid=case_uuid) + if len(case_collection) != 1: + raise ValueError(f"None or multiple sumo cases found {case_uuid=}") + + self._case: Case = case_collection[0] + self._iteration_name: str = iteration_name + + def get_case_name(self) -> str: + """Get name of case to which this iteration belongs""" + return self._case.name + + def get_realizations(self) -> Sequence[int]: + """Get list of realizations for this iteration""" + realizations = self._case.get_realizations(self._iteration_name) + return sorted([int(real) for real in realizations]) diff --git a/backend/src/services/sumo_access/sumo_explore.py b/backend/src/services/sumo_access/sumo_explore.py index a65daf8ce..dc66d6c83 100644 --- a/backend/src/services/sumo_access/sumo_explore.py +++ b/backend/src/services/sumo_access/sumo_explore.py @@ -1,7 +1,7 @@ -from typing import List, Sequence +from typing import List from pydantic import BaseModel -from fmu.sumo.explorer.explorer import CaseCollection, SumoClient +from fmu.sumo.explorer.explorer import CaseCollection, Case, SumoClient from ._helpers import create_sumo_client_instance @@ -13,6 +13,7 @@ class CaseInfo(BaseModel): class IterationInfo(BaseModel): name: str + realization_count: int class SumoExplore: @@ -36,18 +37,13 @@ def get_iterations(self, case_uuid: str) -> List[IterationInfo]: if len(case_collection) != 1: raise ValueError(f"Sumo case not found {case_uuid=}") - case = case_collection[0] + case: Case = case_collection[0] iter_info_arr: List[IterationInfo] = [ - IterationInfo(name=iteration.get("name")) for iteration in case.iterations + IterationInfo(name=iteration.get("name"), realization_count=iteration.get("realizations")) + for iteration in case.iterations ] # Sort on iteration name before returning iter_info_arr.sort(key=lambda iter_info: iter_info.name) return iter_info_arr - - def get_realizations(self, case_uuid: str, ensemble_name: str) -> Sequence[int]: - """Get list of realizations for a case and ensemble""" - case_collection = CaseCollection(self._sumo_client).filter(uuid=case_uuid) - realizations = case_collection[0].get_realizations(ensemble_name) - return sorted([int(real) for real in realizations]) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7c6c36791..38cd28a36 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -11,16 +11,17 @@ export type { OpenAPIConfig } from './core/OpenAPI'; export type { B64EncodedNumpyArray } from './models/B64EncodedNumpyArray'; export type { Body_get_realizations_response } from './models/Body_get_realizations_response'; -export type { Case } from './models/Case'; +export type { CaseInfo } from './models/CaseInfo'; export type { DynamicSurfaceDirectory } from './models/DynamicSurfaceDirectory'; -export type { Ensemble } from './models/Ensemble'; export type { EnsembleCorrelations } from './models/EnsembleCorrelations'; +export type { EnsembleDetails } from './models/EnsembleDetails'; +export type { EnsembleInfo } from './models/EnsembleInfo'; export type { EnsembleParameter } from './models/EnsembleParameter'; export type { EnsembleParameterDescription } from './models/EnsembleParameterDescription'; export type { EnsembleScalarResponse } from './models/EnsembleScalarResponse'; export type { EnsembleSensitivity } from './models/EnsembleSensitivity'; export type { EnsembleSensitivityCase } from './models/EnsembleSensitivityCase'; -export type { Field } from './models/Field'; +export type { FieldInfo } from './models/FieldInfo'; export { Frequency } from './models/Frequency'; export type { GridIntersection } from './models/GridIntersection'; export type { GridSurface } from './models/GridSurface'; diff --git a/frontend/src/api/models/Case.ts b/frontend/src/api/models/CaseInfo.ts similarity index 81% rename from frontend/src/api/models/Case.ts rename to frontend/src/api/models/CaseInfo.ts index 5373411ca..9cc9a1231 100644 --- a/frontend/src/api/models/Case.ts +++ b/frontend/src/api/models/CaseInfo.ts @@ -2,7 +2,7 @@ /* tslint:disable */ /* eslint-disable */ -export type Case = { +export type CaseInfo = { uuid: string; name: string; }; diff --git a/frontend/src/api/models/EnsembleDetails.ts b/frontend/src/api/models/EnsembleDetails.ts new file mode 100644 index 000000000..71ded6bcc --- /dev/null +++ b/frontend/src/api/models/EnsembleDetails.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type EnsembleDetails = { + name: string; + case_name: string; + case_uuid: string; + realizations: Array; +}; + diff --git a/frontend/src/api/models/Ensemble.ts b/frontend/src/api/models/EnsembleInfo.ts similarity index 60% rename from frontend/src/api/models/Ensemble.ts rename to frontend/src/api/models/EnsembleInfo.ts index fd85852b9..5fb2a8c8d 100644 --- a/frontend/src/api/models/Ensemble.ts +++ b/frontend/src/api/models/EnsembleInfo.ts @@ -2,7 +2,8 @@ /* tslint:disable */ /* eslint-disable */ -export type Ensemble = { +export type EnsembleInfo = { name: string; + realization_count: number; }; diff --git a/frontend/src/api/models/Field.ts b/frontend/src/api/models/FieldInfo.ts similarity index 80% rename from frontend/src/api/models/Field.ts rename to frontend/src/api/models/FieldInfo.ts index bd485bcbc..35e031b27 100644 --- a/frontend/src/api/models/Field.ts +++ b/frontend/src/api/models/FieldInfo.ts @@ -2,7 +2,7 @@ /* tslint:disable */ /* eslint-disable */ -export type Field = { +export type FieldInfo = { field_identifier: string; }; diff --git a/frontend/src/api/services/ExploreService.ts b/frontend/src/api/services/ExploreService.ts index e62d098df..c4537792b 100644 --- a/frontend/src/api/services/ExploreService.ts +++ b/frontend/src/api/services/ExploreService.ts @@ -1,9 +1,10 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { Case } from '../models/Case'; -import type { Ensemble } from '../models/Ensemble'; -import type { Field } from '../models/Field'; +import type { CaseInfo } from '../models/CaseInfo'; +import type { EnsembleDetails } from '../models/EnsembleDetails'; +import type { EnsembleInfo } from '../models/EnsembleInfo'; +import type { FieldInfo } from '../models/FieldInfo'; import type { CancelablePromise } from '../core/CancelablePromise'; import type { BaseHttpRequest } from '../core/BaseHttpRequest'; @@ -15,10 +16,10 @@ export class ExploreService { /** * Get Fields * Get list of fields - * @returns Field Successful Response + * @returns FieldInfo Successful Response * @throws ApiError */ - public getFields(): CancelablePromise> { + public getFields(): CancelablePromise> { return this.httpRequest.request({ method: 'GET', url: '/fields', @@ -29,12 +30,12 @@ export class ExploreService { * Get Cases * Get list of cases for specified field * @param fieldIdentifier Field identifier - * @returns Case Successful Response + * @returns CaseInfo Successful Response * @throws ApiError */ public getCases( fieldIdentifier: string, - ): CancelablePromise> { + ): CancelablePromise> { return this.httpRequest.request({ method: 'GET', url: '/cases', @@ -51,12 +52,12 @@ export class ExploreService { * Get Ensembles * Get list of ensembles for a case * @param caseUuid Sumo case uuid - * @returns Ensemble Successful Response + * @returns EnsembleInfo Successful Response * @throws ApiError */ public getEnsembles( caseUuid: string, - ): CancelablePromise> { + ): CancelablePromise> { return this.httpRequest.request({ method: 'GET', url: '/cases/{case_uuid}/ensembles', @@ -70,20 +71,20 @@ export class ExploreService { } /** - * Get Realizations - * Get list of realizations for an ensemble + * Get Ensemble Details + * Get more detailed information for an ensemble * @param caseUuid Sumo case uuid * @param ensembleName Ensemble name - * @returns number Successful Response + * @returns EnsembleDetails Successful Response * @throws ApiError */ - public getRealizations( + public getEnsembleDetails( caseUuid: string, ensembleName: string, - ): CancelablePromise> { + ): CancelablePromise { return this.httpRequest.request({ method: 'GET', - url: '/cases/{case_uuid}/ensembles/{ensemble_name}/realizations', + url: '/cases/{case_uuid}/ensembles/{ensemble_name}', path: { 'case_uuid': caseUuid, 'ensemble_name': ensembleName, diff --git a/frontend/src/framework/Ensemble.ts b/frontend/src/framework/Ensemble.ts new file mode 100644 index 000000000..33fd30a4b --- /dev/null +++ b/frontend/src/framework/Ensemble.ts @@ -0,0 +1,81 @@ +import { EnsembleIdent } from "./EnsembleIdent"; + +export enum SensitivityType { + MONTECARLO = "montecarlo", + SCENARIO = "scenario", +} + +export type SensitivityCase = { + readonly name: string; + readonly realizations: number[]; +}; + +export type Sensitivity = { + readonly name: string; + readonly type: SensitivityType; + readonly cases: SensitivityCase[]; +}; + +export class Ensemble { + private _ensembleIdent: EnsembleIdent; + private _caseName: string; + private _realizationsArr: number[]; + private _sensitivityArr: Sensitivity[]; + + constructor( + caseUuid: string, + caseName: string, + ensembleName: string, + realizationsArr: number[], + sensitivityArr: Sensitivity[] | null + ) { + this._ensembleIdent = new EnsembleIdent(caseUuid, ensembleName); + this._caseName = caseName; + this._realizationsArr = Array.from(realizationsArr).sort((a, b) => a - b); + this._sensitivityArr = sensitivityArr ? sensitivityArr : []; + } + + getIdent(): EnsembleIdent { + return this._ensembleIdent; + } + + getDisplayName(): string { + return `${this._ensembleIdent.getEnsembleName()} (${this._caseName})`; + } + + getCaseUuid(): string { + return this._ensembleIdent.getCaseUuid(); + } + + getEnsembleName(): string { + return this._ensembleIdent.getEnsembleName(); + } + + getCaseName(): string { + return this._caseName; + } + + getRealizations(): readonly number[] { + return this._realizationsArr; + } + + getRealizationCount(): number { + return this._realizationsArr.length; + } + + getMaxRealizationNumber(): number | undefined { + if (this._realizationsArr.length == 0) { + return undefined; + } + + return this._realizationsArr[this._realizationsArr.length - 1]; + } + + hasSensitivities(): boolean { + return this._sensitivityArr && this._sensitivityArr.length > 0; + } + + getSensitivities(): readonly Sensitivity[] { + return this._sensitivityArr; + } +} diff --git a/frontend/src/framework/EnsembleSet.ts b/frontend/src/framework/EnsembleSet.ts new file mode 100644 index 000000000..bcf6ebded --- /dev/null +++ b/frontend/src/framework/EnsembleSet.ts @@ -0,0 +1,41 @@ +import { Ensemble } from "./Ensemble"; +import { EnsembleIdent } from "./EnsembleIdent"; + +export class EnsembleSet { + private _ensembleArr: Ensemble[]; + + constructor(ensembles: Ensemble[]) { + this._ensembleArr = ensembles; + } + + /** + * Returns true if there is at least one ensemble in the set. + */ + hasAnyEnsembles(): boolean { + return this._ensembleArr.length > 0; + } + + findEnsemble(ensembleIdent: EnsembleIdent): Ensemble | null { + return this._ensembleArr.find(ens => ens.getIdent().equals(ensembleIdent)) ?? null; + } + + findEnsembleByIdentString(ensembleIdentString: string): Ensemble | null { + try { + const ensembleIdent = EnsembleIdent.fromString(ensembleIdentString); + return this.findEnsemble(ensembleIdent); + } + catch { + return null; + } + } + + getEnsembleArr(): readonly Ensemble[] { + return this._ensembleArr; + } + + // Temporary helper method + findCaseName(ensembleIdent: EnsembleIdent): string { + const foundEnsemble = this.findEnsemble(ensembleIdent); + return foundEnsemble?.getCaseName() ?? ""; + } +} diff --git a/frontend/src/framework/Module.tsx b/frontend/src/framework/Module.tsx index 25acad7ff..36f21f738 100644 --- a/frontend/src/framework/Module.tsx +++ b/frontend/src/framework/Module.tsx @@ -9,9 +9,11 @@ import { StateBaseType, StateOptions } from "./StateStore"; import { SyncSettingKey } from "./SyncSettings"; import { Workbench } from "./Workbench"; import { WorkbenchServices } from "./WorkbenchServices"; +import { WorkbenchSession } from "./WorkbenchSession"; export type ModuleFCProps = { moduleContext: ModuleContext; + workbenchSession: WorkbenchSession; workbenchServices: WorkbenchServices; }; diff --git a/frontend/src/framework/Workbench.ts b/frontend/src/framework/Workbench.ts index 9022df4ab..bb09a7d11 100644 --- a/frontend/src/framework/Workbench.ts +++ b/frontend/src/framework/Workbench.ts @@ -1,12 +1,16 @@ -import { Ensemble } from "@shared-types/ensemble"; +import { QueryClient } from "@tanstack/react-query"; import { Broadcaster } from "./Broadcaster"; +import { EnsembleIdent } from "./EnsembleIdent"; import { ImportState } from "./Module"; import { ModuleInstance } from "./ModuleInstance"; import { ModuleRegistry } from "./ModuleRegistry"; import { StateStore } from "./StateStore"; import { WorkbenchServices } from "./WorkbenchServices"; +import { WorkbenchSession } from "./WorkbenchSession"; +import { loadEnsembleSetMetadataFromBackend } from "./internal/EnsembleSetLoader"; import { PrivateWorkbenchServices } from "./internal/PrivateWorkbenchServices"; +import { WorkbenchSessionPrivate } from "./internal/WorkbenchSessionPrivate"; export enum WorkbenchEvents { ActiveModuleChanged = "ActiveModuleChanged", @@ -23,10 +27,6 @@ export type LayoutElement = { relWidth: number; }; -export type WorkbenchDataState = { - selectedEnsembles: Ensemble[]; -}; - export type WorkbenchGuiState = { modulesListOpen: boolean; syncSettingsActive: boolean; @@ -36,7 +36,7 @@ export class Workbench { private moduleInstances: ModuleInstance[]; private _activeModuleId: string; private guiStateStore: StateStore; - private dataStateStore: StateStore; + private _workbenchSession: WorkbenchSessionPrivate; private _workbenchServices: PrivateWorkbenchServices; private _broadcaster: Broadcaster; private _subscribersMap: { [key: string]: Set<() => void> }; @@ -49,9 +49,7 @@ export class Workbench { modulesListOpen: false, syncSettingsActive: false, }); - this.dataStateStore = new StateStore({ - selectedEnsembles: [], - }); + this._workbenchSession = new WorkbenchSessionPrivate(); this._workbenchServices = new PrivateWorkbenchServices(this); this._broadcaster = new Broadcaster(); this._subscribersMap = {}; @@ -71,14 +69,14 @@ export class Workbench { return this.guiStateStore; } - public getDataStateStore(): StateStore { - return this.dataStateStore; - } - public getLayout(): LayoutElement[] { return this.layout; } + public getWorkbenchSession(): WorkbenchSession { + return this._workbenchSession; + } + public getWorkbenchServices(): WorkbenchServices { return this._workbenchServices; } @@ -198,7 +196,20 @@ export class Workbench { } } - public setNavigatorEnsembles(ensemblesArr: { caseUuid: string; caseName: string; ensembleName: string }[]) { - this._workbenchServices.publishNavigatorData("navigator.ensembles", ensemblesArr); + public async loadAndSetupEnsembleSetInSession( + queryClient: QueryClient, + specifiedEnsembles: { caseUuid: string; ensembleName: string }[] + ): Promise { + const ensembleIdentsToLoad: EnsembleIdent[] = []; + for (const ensSpec of specifiedEnsembles) { + ensembleIdentsToLoad.push(new EnsembleIdent(ensSpec.caseUuid, ensSpec.ensembleName)); + } + + console.debug("loadAndSetupEnsembleSetInSession - starting load"); + const newEnsembleSet = await loadEnsembleSetMetadataFromBackend(queryClient, ensembleIdentsToLoad); + console.debug("loadAndSetupEnsembleSetInSession - loading done"); + + console.debug("loadAndSetupEnsembleSetInSession - publishing"); + return this._workbenchSession.setEnsembleSet(newEnsembleSet); } } diff --git a/frontend/src/framework/WorkbenchServices.ts b/frontend/src/framework/WorkbenchServices.ts index 642ef15cb..582b11352 100644 --- a/frontend/src/framework/WorkbenchServices.ts +++ b/frontend/src/framework/WorkbenchServices.ts @@ -1,14 +1,13 @@ import React from "react"; -import { Ensemble } from "@shared-types/ensemble"; - import { isEqual } from "lodash"; import { Broadcaster } from "./Broadcaster"; +import { EnsembleIdent } from "./EnsembleIdent"; import { Workbench } from "./Workbench"; export type NavigatorTopicDefinitions = { - "navigator.ensembles": Ensemble[]; + "navigator.dummyPlaceholder": string; }; export type GlobalTopicDefinitions = { @@ -16,7 +15,7 @@ export type GlobalTopicDefinitions = { "global.hoverRealization": { realization: number }; "global.hoverTimestamp": { timestamp: number }; - "global.syncValue.ensembles": Ensemble[]; + "global.syncValue.ensembles": EnsembleIdent[]; "global.syncValue.date": { timeOrInterval: string }; "global.syncValue.timeSeries": { vectorName: string }; "global.syncValue.surface": { name: string; attribute: string }; diff --git a/frontend/src/framework/WorkbenchSession.ts b/frontend/src/framework/WorkbenchSession.ts new file mode 100644 index 000000000..52ae9668f --- /dev/null +++ b/frontend/src/framework/WorkbenchSession.ts @@ -0,0 +1,70 @@ +import React from "react"; + +import { Ensemble } from "./Ensemble"; +import { EnsembleSet } from "./EnsembleSet"; + +export enum WorkbenchSessionEvent { + EnsembleSetChanged = "EnsembleSetChanged", +} + +export class WorkbenchSession { + private _subscribersMap: { [eventKey: string]: Set<() => void> }; + protected _ensembleSet: EnsembleSet; + + protected constructor() { + this._subscribersMap = {}; + this._ensembleSet = new EnsembleSet([]); + } + + getEnsembleSet(): EnsembleSet { + return this._ensembleSet; + } + + subscribe(event: WorkbenchSessionEvent, cb: () => void) { + const subscribersSet = this._subscribersMap[event] || new Set(); + subscribersSet.add(cb); + this._subscribersMap[event] = subscribersSet; + return () => { + subscribersSet.delete(cb); + }; + } + + protected notifySubscribers(event: WorkbenchSessionEvent): void { + const subscribersSet = this._subscribersMap[event]; + if (!subscribersSet) return; + + for (const callbackFn of subscribersSet) { + callbackFn(); + } + } +} + +export function useEnsembleSet(workbenchSession: WorkbenchSession): EnsembleSet { + const [storedEnsembleSet, setStoredEnsembleSet] = React.useState(workbenchSession.getEnsembleSet()); + + React.useEffect( + function subscribeToEnsembleSetChanges() { + function handleEnsembleSetChanged() { + setStoredEnsembleSet(workbenchSession.getEnsembleSet()); + } + + const unsubFunc = workbenchSession.subscribe( + WorkbenchSessionEvent.EnsembleSetChanged, + handleEnsembleSetChanged + ); + return unsubFunc; + }, + [workbenchSession] + ); + + return storedEnsembleSet; +} + +export function useFirstEnsembleInEnsembleSet(workbenchSession: WorkbenchSession): Ensemble | null { + const ensembleSet = useEnsembleSet(workbenchSession); + if (!ensembleSet.hasAnyEnsembles()) { + return null; + } + + return ensembleSet.getEnsembleArr()[0]; +} diff --git a/frontend/src/framework/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx b/frontend/src/framework/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx index 705b9afab..2e40b2e9b 100644 --- a/frontend/src/framework/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx +++ b/frontend/src/framework/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx @@ -46,6 +46,7 @@ export const ViewContent = React.memo((props: ViewContentProps) => { return ( ); diff --git a/frontend/src/framework/components/EnsembleSelector/ensembleSelector.tsx b/frontend/src/framework/components/EnsembleSelector/ensembleSelector.tsx index c025ffbbb..c7f4859cf 100644 --- a/frontend/src/framework/components/EnsembleSelector/ensembleSelector.tsx +++ b/frontend/src/framework/components/EnsembleSelector/ensembleSelector.tsx @@ -1,8 +1,7 @@ import React from "react"; -import { Case, Ensemble, Field } from "@api"; +import { CaseInfo, EnsembleInfo, FieldInfo } from "@api"; import { apiService } from "@framework/ApiService"; -import { useStoreState } from "@framework/StateStore"; import { Workbench } from "@framework/Workbench"; import { TrashIcon } from "@heroicons/react/20/solid"; import { ApiStateWrapper } from "@lib/components/ApiStateWrapper"; @@ -12,7 +11,13 @@ import { Dropdown } from "@lib/components/Dropdown"; import { IconButton } from "@lib/components/IconButton"; import { Label } from "@lib/components/Label"; import { Select } from "@lib/components/Select"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +export type EnsembleItem = { + caseUuid: string; + caseName: string; + ensembleName: string; +}; export type EnsembleSelectorProps = { workbench: Workbench; @@ -22,10 +27,7 @@ export const EnsembleSelector: React.FC = (props) => { const [selectedField, setSelectedField] = React.useState(""); const [selectedCaseId, setSelectedCaseId] = React.useState(""); const [selectedEnsembleName, setSelectedEnsembleName] = React.useState(""); - const [selectedEnsembles, setSelectedEnsembles] = useStoreState( - props.workbench.getDataStateStore(), - "selectedEnsembles" - ); + const [selectedEnsembles, setSelectedEnsembles] = React.useState([]); const fieldsQuery = useQuery({ queryKey: ["getFields"], @@ -40,7 +42,7 @@ export const EnsembleSelector: React.FC = (props) => { queryKey: ["getCases", computedFieldIdentifier], queryFn: () => { if (!computedFieldIdentifier) { - return Promise.resolve([]); + return Promise.resolve([]); } return apiService.explore.getCases(computedFieldIdentifier); }, @@ -53,7 +55,7 @@ export const EnsembleSelector: React.FC = (props) => { queryKey: ["getEnsembles", computedCaseUuid], queryFn: () => { if (!computedCaseUuid) { - return Promise.resolve([]); + return Promise.resolve([]); } return apiService.explore.getEnsembles(computedCaseUuid); }, @@ -89,9 +91,15 @@ export const EnsembleSelector: React.FC = (props) => { } } + // Is this the best way to get hold of the QueryClient + // Revisit this when we refactor the ensemble selection dialog + const queryClient = useQueryClient(); + React.useEffect( - function publishSelectionViaWorkbench() { - props.workbench.setNavigatorEnsembles(selectedEnsembles); + // We should not be pushing the ensemble selection out to the workbench continuously, + // but rather wait until the OK button (in the containing dialog) is pushed. + function loadEnsembleSetViaWorkbench() { + props.workbench.loadAndSetupEnsembleSetInSession(queryClient, selectedEnsembles); }, [selectedEnsembles] ); @@ -104,7 +112,7 @@ export const EnsembleSelector: React.FC = (props) => { const fieldOpts = fieldsQuery.data?.map((f) => ({ value: f.field_identifier, label: f.field_identifier })) ?? []; const caseOpts = casesQuery.data?.map((c) => ({ value: c.uuid, label: c.name })) ?? []; - const ensembleOpts = ensemblesQuery.data?.map((e) => ({ value: e.name, label: e.name })) ?? []; + const ensembleOpts = ensemblesQuery.data?.map((e) => ({ value: e.name, label: `${e.name} (${e.realization_count} reals)` })) ?? []; return (
@@ -203,7 +211,7 @@ export const EnsembleSelector: React.FC = (props) => { ); }; -function fixupFieldIdentifier(currFieldIdentifier: string, fieldArr: Field[] | undefined): string { +function fixupFieldIdentifier(currFieldIdentifier: string, fieldArr: FieldInfo[] | undefined): string { const fieldIdentifiers = fieldArr ? fieldArr.map((item) => item.field_identifier) : []; if (currFieldIdentifier && fieldIdentifiers.includes(currFieldIdentifier)) { return currFieldIdentifier; @@ -216,7 +224,7 @@ function fixupFieldIdentifier(currFieldIdentifier: string, fieldArr: Field[] | u return ""; } -function fixupCaseUuid(currCaseUuid: string, caseArr: Case[] | undefined): string { +function fixupCaseUuid(currCaseUuid: string, caseArr: CaseInfo[] | undefined): string { const caseIds = caseArr ? caseArr.map((item) => item.uuid) : []; if (currCaseUuid && caseIds.includes(currCaseUuid)) { return currCaseUuid; @@ -229,7 +237,7 @@ function fixupCaseUuid(currCaseUuid: string, caseArr: Case[] | undefined): strin return ""; } -function fixupEnsembleName(currEnsembleName: string, ensembleArr: Ensemble[] | undefined): string { +function fixupEnsembleName(currEnsembleName: string, ensembleArr: EnsembleInfo[] | undefined): string { const ensembleNames = ensembleArr ? ensembleArr.map((item) => item.name) : []; if (currEnsembleName && ensembleNames.includes(currEnsembleName)) { return currEnsembleName; diff --git a/frontend/src/framework/components/MultiEnsembleSelect/index.ts b/frontend/src/framework/components/MultiEnsembleSelect/index.ts new file mode 100644 index 000000000..9f1ae82d1 --- /dev/null +++ b/frontend/src/framework/components/MultiEnsembleSelect/index.ts @@ -0,0 +1 @@ +export { MultiEnsembleSelect } from "./multiEnsembleSelect"; diff --git a/frontend/src/framework/components/MultiEnsembleSelect/multiEnsembleSelect.tsx b/frontend/src/framework/components/MultiEnsembleSelect/multiEnsembleSelect.tsx new file mode 100644 index 000000000..e04c0a0fa --- /dev/null +++ b/frontend/src/framework/components/MultiEnsembleSelect/multiEnsembleSelect.tsx @@ -0,0 +1,38 @@ +import { Select, SelectProps, SelectOption } from "@lib/components/Select"; + +import { EnsembleIdent } from "../../EnsembleIdent"; +import { EnsembleSet } from "../../EnsembleSet"; + +type MultiEnsembleSelectProps = { + ensembleSet: EnsembleSet; + value: EnsembleIdent[]; + onChange: (ensembleIdentArr: EnsembleIdent[]) => void; +} & Omit; + +export function MultiEnsembleSelect(props: MultiEnsembleSelectProps): JSX.Element { + const {ensembleSet, value, onChange, ...rest} = props; + + function handleSelectionChanged(selectedEnsembleIdentStrArr: string[]) { + const identArr: EnsembleIdent[] = []; + for (const identStr of selectedEnsembleIdentStrArr) { + const foundEnsemble = ensembleSet.findEnsembleByIdentString(identStr); + if (foundEnsemble) { + identArr.push(foundEnsemble.getIdent()); + } + } + + onChange(identArr); + } + + const optionsArr: SelectOption[] = []; + for (const ens of ensembleSet.getEnsembleArr()) { + optionsArr.push({ value: ens.getIdent().toString(), label: ens.getDisplayName() }); + } + + const selectedArr: string[] = []; + for (const ident of value) { + selectedArr.push(ident.toString()); + } + + return @@ -156,7 +160,7 @@ export function settings({ moduleContext, workbenchServices }: ModuleFCProps - @@ -149,37 +148,6 @@ export function settings({ moduleContext, workbenchServices }: ModuleFCProps item.caseUuid === currEnsemble.caseUuid && item.ensembleName == currEnsemble.ensembleName - ); - if (foundItem) { - return foundItem; - } - } - - return availableEnsemblesArr[0]; -} - -function encodeEnsembleAsIdStr(ensemble: Ensemble): string { - return `${ensemble.caseUuid}::${ensemble.ensembleName}`; -} - -function makeEnsembleOptionItems(ensemblesArr: Ensemble[] | null): DropdownOption[] { - const itemArr: DropdownOption[] = []; - if (ensemblesArr) { - for (const ens of ensemblesArr) { - itemArr.push({ value: encodeEnsembleAsIdStr(ens), label: `${ens.ensembleName} (${ens.caseName})` }); - } - } - return itemArr; -} - function isValidVectorName(vectorName: string, vectorDescriptionsArr: VectorDescription[] | undefined): boolean { if (!vectorName || !vectorDescriptionsArr || vectorDescriptionsArr.length === 0) { return false; diff --git a/frontend/src/shared-types/ensemble.ts b/frontend/src/shared-types/ensemble.ts deleted file mode 100644 index 96a924812..000000000 --- a/frontend/src/shared-types/ensemble.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type Ensemble = { - caseUuid: string; - caseName: string; - ensembleName: string; -}; diff --git a/frontend/tests/unit-tests/EnsembleSet.test.ts b/frontend/tests/unit-tests/EnsembleSet.test.ts new file mode 100644 index 000000000..e62b968e4 --- /dev/null +++ b/frontend/tests/unit-tests/EnsembleSet.test.ts @@ -0,0 +1,42 @@ +import { Ensemble } from "@framework/Ensemble"; +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { EnsembleSet } from "@framework/EnsembleSet"; + +const ensembleArr = [ + new Ensemble("11111111-aaaa-4444-aaaa-aaaaaaaaaaaa", "case1", "ens1", [], null), + new Ensemble("11111111-aaaa-4444-aaaa-aaaaaaaaaaaa", "case1", "ens2", [], null), + new Ensemble("22222222-aaaa-4444-aaaa-aaaaaaaaaaaa", "case2", "ens1", [], null), +]; + + +describe("EnsembleSet tests", () => { + test("access empty EnsembleSet", () => { + const ensSet = new EnsembleSet([]); + expect(ensSet.hasAnyEnsembles()).toBe(false); + expect(ensSet.getEnsembleArr().length).toBe(0); + expect(ensSet.findEnsemble(new EnsembleIdent("11111111-aaaa-4444-aaaa-aaaaaaaaaaaa", "ens1"))).toBeNull(); + }); + + test("find by EnsembleIdent", () => { + const ensSet = new EnsembleSet(ensembleArr); + expect(ensSet.hasAnyEnsembles()).toBe(true); + expect(ensSet.findEnsemble(new EnsembleIdent("11111111-aaaa-4444-aaaa-aaaaaaaaaaaa", "ens1"))).toBeInstanceOf(Ensemble); + expect(ensSet.findEnsemble(new EnsembleIdent("11111111-aaaa-4444-aaaa-aaaaaaaaaaaa", "ens99"))).toBeNull(); + expect(ensSet.findEnsemble(new EnsembleIdent("99999999-aaaa-4444-aaaa-aaaaaaaaaaaa", "ens1"))).toBeNull(); + }); + + test("find by EnsembleIdentString", () => { + const ensSet = new EnsembleSet(ensembleArr); + expect(ensSet.hasAnyEnsembles()).toBe(true); + expect(ensSet.findEnsembleByIdentString("11111111-aaaa-4444-aaaa-aaaaaaaaaaaa::ens1")).toBeInstanceOf(Ensemble); + expect(ensSet.findEnsembleByIdentString("11111111-aaaa-4444-aaaa-aaaaaaaaaaaa::ens99")).toBeNull(); + expect(ensSet.findEnsembleByIdentString("99999999-aaaa-4444-aaaa-aaaaaaaaaaaa::ens1")).toBeNull(); + }); + + test("find by EnsembleIdentString containing invalid UUID", () => { + const ensSet = new EnsembleSet(ensembleArr); + expect(ensSet.findEnsembleByIdentString("")).toBeNull(); + expect(ensSet.findEnsembleByIdentString("")).toBeNull(); + expect(ensSet.findEnsembleByIdentString("QQQQQQQQ-aaaa-4444-aaaa-aaaaaaaaaaaa::ens99")).toBeNull(); + }); +});