diff --git a/app/packages/operators/src/CustomPanel.tsx b/app/packages/operators/src/CustomPanel.tsx index e463a29300..2f09ef580c 100644 --- a/app/packages/operators/src/CustomPanel.tsx +++ b/app/packages/operators/src/CustomPanel.tsx @@ -118,6 +118,7 @@ export function defineCustomPanel({ on_change_selected_labels, on_change_extended_selection, on_change_group_slice, + on_change_spaces, panel_name, panel_label, }) { @@ -135,6 +136,7 @@ export function defineCustomPanel({ onChangeSelectedLabels={on_change_selected_labels} onChangeExtendedSelection={on_change_extended_selection} onChangeGroupSlice={on_change_group_slice} + onChangeSpaces={on_change_spaces} dimensions={dimensions} panelName={panel_name} panelLabel={panel_label} diff --git a/app/packages/operators/src/hooks.ts b/app/packages/operators/src/hooks.ts index a636f1b263..855c6a2b1c 100644 --- a/app/packages/operators/src/hooks.ts +++ b/app/packages/operators/src/hooks.ts @@ -28,6 +28,8 @@ function useOperatorThrottledContextSetter() { const groupSlice = useRecoilValue(fos.groupSlice); const currentSample = useCurrentSample(); const setContext = useSetRecoilState(operatorThrottledContext); + const spaces = useRecoilValue(fos.sessionSpaces); + const workspaceName = spaces._name; const setThrottledContext = useMemo(() => { return debounce( (context) => { @@ -49,6 +51,8 @@ function useOperatorThrottledContextSetter() { currentSample, viewName, groupSlice, + spaces, + workspaceName, }); }, [ setThrottledContext, @@ -61,6 +65,8 @@ function useOperatorThrottledContextSetter() { currentSample, viewName, groupSlice, + spaces, + workspaceName, ]); } diff --git a/app/packages/operators/src/operators.ts b/app/packages/operators/src/operators.ts index d16f175fba..a96f505406 100644 --- a/app/packages/operators/src/operators.ts +++ b/app/packages/operators/src/operators.ts @@ -1,5 +1,6 @@ import { AnalyticsInfo, usingAnalytics } from "@fiftyone/analytics"; -import { ServerError, getFetchFunction, isNullish } from "@fiftyone/utilities"; +import { SpaceNode, spaceNodeFromJSON, SpaceNodeJSON } from "@fiftyone/spaces"; +import { getFetchFunction, isNullish, ServerError } from "@fiftyone/utilities"; import { CallbackInterface } from "recoil"; import { QueueItemStatus } from "./constants"; import * as types from "./types"; @@ -92,6 +93,8 @@ export type RawContext = { }; groupSlice: string; queryPerformance?: boolean; + spaces: SpaceNodeJSON; + workspaceName: string; }; export class ExecutionContext { @@ -140,6 +143,12 @@ export class ExecutionContext { public get queryPerformance(): boolean { return Boolean(this._currentContext.queryPerformance); } + public get spaces(): SpaceNode { + return spaceNodeFromJSON(this._currentContext.spaces); + } + public get workspaceName(): string { + return this._currentContext.workspaceName; + } getCurrentPanelId(): string | null { return this.params.panel_id || this.currentPanel?.id || null; @@ -548,6 +557,8 @@ async function executeOperatorAsGenerator( view: currentContext.view, view_name: currentContext.viewName, group_slice: currentContext.groupSlice, + spaces: currentContext.spaces, + workspace_name: currentContext.workspaceName, }, "json-stream" ); @@ -712,6 +723,8 @@ export async function executeOperatorWithContext( view_name: currentContext.viewName, group_slice: currentContext.groupSlice, query_performance: currentContext.queryPerformance, + spaces: currentContext.spaces, + workspace_name: currentContext.workspaceName, } ); result = serverResult.result; @@ -815,6 +828,8 @@ export async function resolveRemoteType( view: currentContext.view, view_name: currentContext.viewName, group_slice: currentContext.groupSlice, + spaces: currentContext.spaces, + workspace_name: currentContext.workspaceName, } ); @@ -889,6 +904,8 @@ export async function resolveExecutionOptions( view: currentContext.view, view_name: currentContext.viewName, group_slice: currentContext.groupSlice, + spaces: currentContext.spaces, + workspace_name: currentContext.workspaceName, } ); @@ -920,6 +937,8 @@ export async function fetchRemotePlacements(ctx: ExecutionContext) { current_sample: currentContext.currentSample, view_name: currentContext.viewName, group_slice: currentContext.groupSlice, + spaces: currentContext.spaces, + workspace_name: currentContext.workspaceName, } ); if (result && result.error) { diff --git a/app/packages/operators/src/state.ts b/app/packages/operators/src/state.ts index cf0f08cd14..535f61c0c2 100644 --- a/app/packages/operators/src/state.ts +++ b/app/packages/operators/src/state.ts @@ -95,6 +95,8 @@ const globalContextSelector = selector({ const extendedSelection = get(fos.extendedSelection); const groupSlice = get(fos.groupSlice); const queryPerformance = typeof get(fos.lightningThreshold) === "number"; + const spaces = get(fos.sessionSpaces); + const workspaceName = spaces?._name; return { datasetName, @@ -107,6 +109,8 @@ const globalContextSelector = selector({ extendedSelection, groupSlice, queryPerformance, + spaces, + workspaceName, }; }, }); @@ -148,6 +152,8 @@ const useExecutionContext = (operatorName, hooks = {}) => { extendedSelection, groupSlice, queryPerformance, + spaces, + workspaceName, } = curCtx; const [analyticsInfo] = useAnalyticsInfo(); const ctx = useMemo(() => { @@ -166,6 +172,8 @@ const useExecutionContext = (operatorName, hooks = {}) => { analyticsInfo, groupSlice, queryPerformance, + spaces, + workspaceName, }, hooks ); @@ -182,6 +190,8 @@ const useExecutionContext = (operatorName, hooks = {}) => { currentSample, groupSlice, queryPerformance, + spaces, + workspaceName, ]); return ctx; diff --git a/app/packages/operators/src/useCustomPanelHooks.ts b/app/packages/operators/src/useCustomPanelHooks.ts index afbaf851dd..155f67b98c 100644 --- a/app/packages/operators/src/useCustomPanelHooks.ts +++ b/app/packages/operators/src/useCustomPanelHooks.ts @@ -26,6 +26,8 @@ export interface CustomPanelProps { onChangeSelectedLabels?: string; onChangeExtendedSelection?: string; onChangeGroupSlice?: string; + onChangeSpaces?: string; + onChangeWorkspace?: string; dimensions: DimensionsType | null; panelName?: string; panelLabel?: string; @@ -136,6 +138,13 @@ export function useCustomPanelHooks(props: CustomPanelProps): CustomPanelHooks { ctx.groupSlice, props.onChangeGroupSlice ); + useCtxChangePanelEvent(isLoaded, panelId, ctx.spaces, props.onChangeSpaces); + useCtxChangePanelEvent( + isLoaded, + panelId, + ctx.workspaceName, + props.onChangeWorkspace + ); useEffect(() => { onLoad(); diff --git a/docs/source/plugins/developing_plugins.rst b/docs/source/plugins/developing_plugins.rst index 1ee0f0e643..ed6532c423 100644 --- a/docs/source/plugins/developing_plugins.rst +++ b/docs/source/plugins/developing_plugins.rst @@ -974,6 +974,7 @@ contains the following properties: - `ctx.dataset_name`: the name of the current dataset - `ctx.dataset` - the current |Dataset| instance - `ctx.view` - the current |DatasetView| instance +- `ctx.spaces` - the current :ref:`Spaces layout ` in the App - `ctx.current_sample` - the ID of the active sample in the App modal, if any - `ctx.selected` - the list of currently selected samples in the App, if any - `ctx.selected_labels` - the list of currently selected labels in the App, @@ -1996,6 +1997,19 @@ subsequent sections. ctx.panel.set_state("event", "on_change_view") ctx.panel.set_data("event_data", event) + def on_change_spaces(self, ctx): + """Implement this method to set panel state/data when the current + spaces layout changes. + + The current spaces layout will be available via ``ctx.spaces``. + """ + event = { + "data": ctx.spaces, + "description": "the current spaces layout", + } + ctx.panel.set_state("event", "on_change_spaces") + ctx.panel.set_data("event_data", event) + def on_change_current_sample(self, ctx): """Implement this method to set panel state/data when a new sample is loaded in the Sample modal. diff --git a/fiftyone/operators/builtin.py b/fiftyone/operators/builtin.py index 12d4ee76bb..57783f0842 100644 --- a/fiftyone/operators/builtin.py +++ b/fiftyone/operators/builtin.py @@ -14,6 +14,7 @@ import fiftyone.core.storage as fos import fiftyone.operators as foo import fiftyone.operators.types as types +from fiftyone.core.odm.workspace import default_workspace_factory class EditFieldInfo(foo.Operator): @@ -1939,28 +1940,6 @@ def resolve_input(self, ctx): ), ) - # @todo infer this automatically from current App spaces - spaces_prop = inputs.oneof( - "spaces", - [types.String(), types.Object()], - default=None, - required=True, - label="Spaces", - description=( - "JSON description of the workspace to save: " - "`print(session.spaces.to_json(True))`" - ), - view=types.CodeView(), - ) - - spaces = ctx.params.get("spaces", None) - if spaces is not None: - try: - _parse_spaces(spaces) - except: - spaces_prop.invalid = True - spaces_prop.error_message = "Invalid workspace definition" - name = ctx.params.get("name", None) if name in workspaces: @@ -1979,7 +1958,11 @@ def execute(self, ctx): color = ctx.params.get("color", None) spaces = ctx.params.get("spaces", None) - spaces = _parse_spaces(spaces) + curr_spaces = spaces is None + if curr_spaces: + spaces = ctx.spaces + else: + spaces = _parse_spaces(ctx, spaces) ctx.dataset.save_workspace( name, @@ -1989,6 +1972,9 @@ def execute(self, ctx): overwrite=True, ) + if curr_spaces: + ctx.ops.set_spaces(name=name) + class EditWorkspaceInfo(foo.Operator): @property @@ -2014,9 +2000,14 @@ def execute(self, ctx): description = ctx.params.get("description", None) color = ctx.params.get("color", None) + curr_name = ctx.spaces.name info = dict(name=new_name, description=description, color=color) + ctx.dataset.update_workspace_info(name, info) + if curr_name is not None and curr_name != new_name: + ctx.ops.set_spaces(name=new_name) + def _edit_workspace_info_inputs(ctx, inputs): workspaces = ctx.dataset.list_workspaces() @@ -2034,10 +2025,10 @@ def _edit_workspace_info_inputs(ctx, inputs): for key in workspaces: workspace_selector.add_choice(key, label=key) - # @todo default to current workspace name, if one is currently open inputs.enum( "name", workspace_selector.values(), + default=ctx.spaces.name, required=True, label="Workspace", description="The workspace to edit", @@ -2106,7 +2097,7 @@ def resolve_input(self, ctx): inputs.enum( "name", workspace_selector.values(), - default=None, + default=ctx.spaces.name, required=True, label="Workspace", description="The workspace to delete", @@ -2127,8 +2118,12 @@ def resolve_input(self, ctx): def execute(self, ctx): name = ctx.params["name"] + curr_spaces = name == ctx.spaces.name ctx.dataset.delete_workspace(name) + if curr_spaces: + ctx.ops.set_spaces(spaces=default_workspace_factory()) + class SyncLastModifiedAt(foo.Operator): @property @@ -2292,10 +2287,11 @@ def _get_non_default_frame_fields(dataset): return schema -def _parse_spaces(spaces): - if isinstance(spaces, dict): - return fo.Space.from_dict(spaces) - return fo.Space.from_json(spaces) +def _parse_spaces(ctx, spaces): + if isinstance(spaces, str): + spaces = json.loads(spaces) + + return fo.Space.from_dict(spaces) BUILTIN_OPERATORS = [ diff --git a/fiftyone/operators/executor.py b/fiftyone/operators/executor.py index 15eeef3bb2..50c177f423 100644 --- a/fiftyone/operators/executor.py +++ b/fiftyone/operators/executor.py @@ -610,6 +610,19 @@ def has_custom_view(self): ) return has_stages or has_filters or has_extended + @property + def spaces(self): + """The current spaces layout in the FiftyOne App.""" + workspace_name = self.request_params.get("workspace_name", None) + if workspace_name is not None: + return self.dataset.load_workspace(workspace_name) + + spaces_dict = self.request_params.get("spaces", None) + if spaces_dict is not None: + return fo.Space.from_dict(spaces_dict) + + return None + @property def selected(self): """The list of selected sample IDs (if any).""" diff --git a/fiftyone/operators/operations.py b/fiftyone/operators/operations.py index 933f6f3d55..ef31a71c06 100644 --- a/fiftyone/operators/operations.py +++ b/fiftyone/operators/operations.py @@ -310,8 +310,9 @@ def register_panel( on_unload=None, on_change=None, on_change_ctx=None, - on_change_view=None, on_change_dataset=None, + on_change_view=None, + on_change_spaces=None, on_change_current_sample=None, on_change_selected=None, on_change_selected_labels=None, @@ -342,10 +343,12 @@ def register_panel( changes on_change_ctx (None): an operator to invoke when the panel execution context changes - on_change_view (None): an operator to invoke when the current view - changes on_change_dataset (None): an operator to invoke when the current dataset changes + on_change_view (None): an operator to invoke when the current view + changes + on_change_spaces (None): an operator to invoke when the current + spaces layout changes on_change_current_sample (None): an operator to invoke when the current sample changes on_change_selected (None): an operator to invoke when the current @@ -372,8 +375,9 @@ def register_panel( "on_unload": on_unload, "on_change": on_change, "on_change_ctx": on_change_ctx, - "on_change_view": on_change_view, "on_change_dataset": on_change_dataset, + "on_change_view": on_change_view, + "on_change_spaces": on_change_spaces, "on_change_current_sample": on_change_current_sample, "on_change_selected": on_change_selected, "on_change_selected_labels": on_change_selected_labels, diff --git a/fiftyone/operators/panel.py b/fiftyone/operators/panel.py index b9d897c2fd..482519159c 100644 --- a/fiftyone/operators/panel.py +++ b/fiftyone/operators/panel.py @@ -119,8 +119,9 @@ def on_startup(self, ctx): methods = ["on_load", "on_unload", "on_change"] ctx_change_events = [ "on_change_ctx", - "on_change_view", "on_change_dataset", + "on_change_view", + "on_change_spaces", "on_change_current_sample", "on_change_selected", "on_change_selected_labels",