diff --git a/app/packages/state/src/recoil/sidebar.ts b/app/packages/state/src/recoil/sidebar/index.ts similarity index 97% rename from app/packages/state/src/recoil/sidebar.ts rename to app/packages/state/src/recoil/sidebar/index.ts index d92783dbdd..aa5eed3ce1 100644 --- a/app/packages/state/src/recoil/sidebar.ts +++ b/app/packages/state/src/recoil/sidebar/index.ts @@ -35,17 +35,17 @@ import { useRecoilStateLoadable, useRecoilValueLoadable, } from "recoil"; -import { collapseFields, getCurrentEnvironment } from "../utils"; -import * as atoms from "./atoms"; -import { getBrowserStorageEffectForKey } from "./customEffects"; +import { collapseFields, getCurrentEnvironment } from "../../utils"; +import * as atoms from "../atoms"; +import { getBrowserStorageEffectForKey } from "../customEffects"; import { active3dSlices, active3dSlicesToSampleMap, activeModalSidebarSample, pinned3DSampleSlice, -} from "./groups"; -import { isLargeVideo } from "./options"; -import { cumulativeValues, values } from "./pathData"; +} from "../groups"; +import { isLargeVideo } from "../options"; +import { cumulativeValues, values } from "../pathData"; import { buildSchema, field, @@ -54,23 +54,24 @@ import { filterPaths, isOfDocumentFieldList, pathIsShown, -} from "./schema"; -import { isFieldVisibilityActive } from "./schemaSettings.atoms"; +} from "../schema"; +import { isFieldVisibilityActive } from "../schemaSettings.atoms"; import { datasetName, disableFrameFiltering, isVideoDataset, stateSubscription, -} from "./selectors"; -import { State } from "./types"; +} from "../selectors"; +import { State } from "../types"; import { fieldsMatcher, groupFilter, labelsMatcher, primitivesMatcher, unsupportedMatcher, -} from "./utils"; -import * as viewAtoms from "./view"; +} from "../utils"; +import * as viewAtoms from "../view"; +import { mergeGroups } from "./sidebar-utils"; export enum EntryKind { EMPTY = "EMPTY", @@ -211,6 +212,10 @@ export const resolveGroups = ( ? DEFAULT_VIDEO_GROUPS : DEFAULT_IMAGE_GROUPS; + if (currentGroups.length && configGroups.length) { + groups = mergeGroups(groups, configGroups); + } + const expanded = configGroups.reduce((map, { name, expanded }) => { map[name] = expanded; return map; diff --git a/app/packages/state/src/recoil/sidebar/sidebar-utils.test.ts b/app/packages/state/src/recoil/sidebar/sidebar-utils.test.ts new file mode 100644 index 0000000000..5716e01cef --- /dev/null +++ b/app/packages/state/src/recoil/sidebar/sidebar-utils.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("recoil"); +vi.mock("recoil-relay"); + +import { merge, mergeGroups } from "./sidebar-utils"; + +describe("test sidebar groups resolution", () => { + it("test list merge", () => { + expect(merge([], ["one", "two"])).toStrictEqual(["one", "two"]); + }); + + it("merges current and config groups", () => { + expect( + mergeGroups( + [ + { name: "one", paths: ["one.one", "one.three"] }, + { name: "three", paths: [] }, + ], + + [ + { name: "zero", paths: [] }, + { + name: "one", + paths: ["one.zero", "one.one", "one.two"], + }, + { name: "two", paths: [] }, + ] + ) + ).toStrictEqual([ + { name: "zero", paths: [] }, + { name: "one", paths: ["one.zero", "one.one", "one.two", "one.three"] }, + { name: "two", paths: [] }, + { name: "three", paths: [] }, + ]); + }); +}); diff --git a/app/packages/state/src/recoil/sidebar/sidebar-utils.ts b/app/packages/state/src/recoil/sidebar/sidebar-utils.ts new file mode 100644 index 0000000000..9459110749 --- /dev/null +++ b/app/packages/state/src/recoil/sidebar/sidebar-utils.ts @@ -0,0 +1,89 @@ +import type { State } from "../types"; + +const hasNeighbor = (sink: string[], source: string[], key: string) => { + const index = source.indexOf(key); + const before = source[index - 1]; + const after = source[index + 1]; + + return sink.includes(before) || sink.includes(after); +}; + +const insertFromNeighbor = (sink: string[], source: string[], key: string) => { + if (sink.includes(key)) { + return; + } + + const sourceIndex = source.indexOf(key); + const before = source[sourceIndex - 1]; + const beforeIndex = sink.indexOf(before); + + if (beforeIndex >= 0) { + sink.splice(beforeIndex + 1, 0, key); + return; + } + + const after = source[sourceIndex + 1]; + const afterIndex = sink.indexOf(after); + + if (afterIndex >= 0) { + sink.splice(afterIndex, 0, key); + return; + } + + sink.push(key); + return; +}; + +export const merge = (sink: string[], source: string[]) => { + const missing = new Set(source.filter((key) => !sink.includes(key))); + + while (missing.size) { + const force = [...missing].every( + (name) => !hasNeighbor(sink, source, name) + ); + for (const name of missing) { + if (!force && !hasNeighbor(sink, source, name)) { + continue; + } + + insertFromNeighbor(sink, source, name); + + missing.delete(name); + } + } + + return sink; +}; + +export const mergeGroups = ( + sink: State.SidebarGroup[], + source: State.SidebarGroup[] +) => { + // make copies, assume readonly + const mapping = Object.fromEntries(sink.map((g) => [g.name, { ...g }])); + const configMapping = Object.fromEntries( + source.map((g) => [g.name, { ...g }]) + ); + + const sinkKeys = sink.map(({ name }) => name); + const sourceKeys = source.map(({ name }) => name); + + merge(sinkKeys, sourceKeys); + + for (const key of sinkKeys) { + mapping[key] = mapping[key] ?? configMapping[key]; + } + const resolved = sinkKeys.map((g) => mapping[g] ?? configMapping[g]); + for (const { name } of resolved) { + const i = sourceKeys.indexOf(name); + if (i < 0) { + continue; + } + + // make copies, assume readonly + mapping[name].paths = [...(mapping[name].paths ?? [])]; + merge(mapping[name].paths, [...source[i].paths]); + } + + return resolved; +}; diff --git a/fiftyone/core/state.py b/fiftyone/core/state.py index 5f466b7ddd..b6e82b37b4 100644 --- a/fiftyone/core/state.py +++ b/fiftyone/core/state.py @@ -97,7 +97,9 @@ def serialize(self, reflective=True): if self.dataset is not None: d["dataset"] = self.dataset.name - collection = self.dataset + collection = fo.Dataset( + self.dataset.name, _create=False, _force_load=True + ) if self.view is not None: collection = self.view