"""
FiftyOne Server queries

| Copyright 2017-2022, Voxel51, Inc.
| `voxel51.com <https://voxel51.com/>`_
|
"""
import typing as t
from dataclasses import asdict
from datetime import date, datetime
from enum import Enum
import os

import asyncio
import eta.core.serial as etas
import eta.core.utils as etau
import strawberry as gql
from bson import ObjectId, json_util
from dacite import Config, from_dict

import fiftyone as fo
import fiftyone.constants as foc
import fiftyone.core.context as focx
import fiftyone.core.media as fom
from fiftyone.core.odm import SavedViewDocument
from fiftyone.core.state import SampleField, serialize_fields
import fiftyone.core.uid as fou
import fiftyone.core.view as fov

import fiftyone.server.aggregate as fosa
from fiftyone.server.aggregations import aggregate_resolver
from fiftyone.server.data import Info
from fiftyone.server.dataloader import get_dataloader_resolver
import fiftyone.server.events as fose
from fiftyone.server.metadata import MediaType
from fiftyone.server.paginator import Connection, get_paginator_resolver
from fiftyone.server.samples import (
    SampleFilter,
    SampleItem,
    paginate_samples,
)

from fiftyone.server.scalars import BSONArray, JSON

ID = gql.scalar(
    t.NewType("ID", str),
    serialize=lambda v: str(v),
    parse_value=lambda v: ObjectId(v),
)
DATASET_FILTER = [{"sample_collection_name": {"$regex": "^samples\\."}}]
DATASET_FILTER_STAGE = [{"$match": DATASET_FILTER[0]}]


@gql.type
class Group:
    name: str
    media_type: MediaType


@gql.type
class Target:
    target: int
    value: str


@gql.type
class NamedTargets:
    name: str
    targets: t.List[Target]


@gql.interface
class RunConfig:
    cls: str


@gql.interface
class Run:
    key: str
    version: t.Optional[str]
    timestamp: t.Optional[datetime]
    config: t.Optional[RunConfig]
    view_stages: t.Optional[t.List[str]]


@gql.type
class BrainRunConfig(RunConfig):
    embeddings_field: t.Optional[str]
    method: t.Optional[str]
    patches_field: t.Optional[str]


@gql.type
class BrainRun(Run):
    config: t.Optional[BrainRunConfig]


@gql.type
class EvaluationRunConfig(RunConfig):
    gt_field: t.Optional[str]
    pred_field: t.Optional[str]
    method: t.Optional[str]


@gql.type
class EvaluationRun(Run):
    config: t.Optional[EvaluationRunConfig]


@gql.type
class SavedView:
    id: t.Optional[str]
    dataset_id: t.Optional[str]
    name: t.Optional[str]
    slug: t.Optional[str]
    description: t.Optional[str]
    color: t.Optional[str]
    view_stages: t.Optional[t.List[str]]
    created_at: t.Optional[datetime]
    last_modified_at: t.Optional[datetime]
    last_loaded_at: t.Optional[datetime]

    @gql.field
    def view_name(self) -> t.Optional[str]:
        if isinstance(self, ObjectId):
            return None
        return self.name

    @gql.field
    def stage_dicts(self) -> t.Optional[BSONArray]:
        return [json_util.loads(x) for x in self.view_stages]

    @classmethod
    def from_doc(cls, doc: SavedViewDocument):
        stage_dicts = [json_util.loads(x) for x in doc.view_stages]
        saved_view = from_dict(data_class=cls, data=doc.to_dict())
        saved_view.stage_dicts = stage_dicts
        return saved_view


@gql.type
class SidebarGroup:
    name: str
    paths: t.Optional[t.List[str]]
    expanded: t.Optional[bool] = True


@gql.type
class KeypointSkeleton:
    labels: t.Optional[t.List[str]]
    edges: t.List[t.List[int]]


@gql.type
class NamedKeypointSkeleton(KeypointSkeleton):
    name: str


@gql.enum
class SidebarMode(Enum):
    all = "all"
    best = "best"
    fast = "fast"


@gql.type
class DatasetAppConfig:
    media_fields: t.Optional[t.List[str]]
    plugins: t.Optional[JSON]
    sidebar_groups: t.Optional[t.List[SidebarGroup]]
    sidebar_mode: t.Optional[SidebarMode]
    modal_media_field: t.Optional[str] = gql.field(default="filepath")
    grid_media_field: t.Optional[str] = "filepath"


@gql.type
class Dataset:
    id: gql.ID
    name: str
    created_at: t.Optional[date]
    last_loaded_at: t.Optional[datetime]
    persistent: bool
    group_media_types: t.Optional[t.List[Group]]
    group_field: t.Optional[str]
    group_slice: t.Optional[str]
    default_group_slice: t.Optional[str]
    media_type: t.Optional[MediaType]
    mask_targets: t.List[NamedTargets]
    default_mask_targets: t.Optional[t.List[Target]]
    sample_fields: t.List[SampleField]
    frame_fields: t.Optional[t.List[SampleField]]
    brain_methods: t.Optional[t.List[BrainRun]]
    evaluations: t.Optional[t.List[EvaluationRun]]
    saved_views: t.Optional[t.List[SavedView]]
    saved_view_ids: gql.Private[t.Optional[t.List[gql.ID]]]
    version: t.Optional[str]
    view_cls: t.Optional[str]
    view_name: t.Optional[str]
    default_skeleton: t.Optional[KeypointSkeleton]
    skeletons: t.List[NamedKeypointSkeleton]
    app_config: t.Optional[DatasetAppConfig]
    info: t.Optional[JSON]

    @staticmethod
    def modifier(doc: dict) -> dict:
        doc["id"] = doc.pop("_id")
        doc["default_mask_targets"] = _convert_targets(
            doc.get("default_mask_targets", {})
        )
        doc["mask_targets"] = [
            NamedTargets(name=name, targets=_convert_targets(targets))
            for name, targets in doc.get("mask_targets", {}).items()
        ]
        doc["sample_fields"] = _flatten_fields(
            [], doc.get("sample_fields", [])
        )
        doc["frame_fields"] = _flatten_fields([], doc.get("frame_fields", []))
        doc["brain_methods"] = list(doc.get("brain_methods", {}).values())
        doc["evaluations"] = list(doc.get("evaluations", {}).values())
        doc["saved_views"] = doc.get("saved_views", [])
        doc["skeletons"] = list(
            dict(name=name, **data)
            for name, data in doc.get("skeletons", {}).items()
        )
        doc["group_media_types"] = [
            Group(name=name, media_type=media_type)
            for name, media_type in doc.get("group_media_types", {}).items()
        ]
        doc["default_skeletons"] = doc.get("default_skeletons", None)
        return doc

    @classmethod
    async def resolver(
        cls,
        name: str,
        view: t.Optional[BSONArray],
        info: Info,
        view_name: t.Optional[str] = gql.UNSET,
    ) -> t.Optional["Dataset"]:
        return await serialize_dataset(
            dataset_name=name, serialized_view=view, view_name=view_name
        )


dataset_dataloader = get_dataloader_resolver(
    Dataset, "datasets", "name", DATASET_FILTER
)


@gql.enum
class ColorBy(Enum):
    field = "field"
    instance = "instance"
    label = "label"


@gql.enum
class Theme(Enum):
    browser = "browser"
    dark = "dark"
    light = "light"


@gql.type
class AppConfig:
    color_by: ColorBy
    color_pool: t.List[str]
    colorscale: str
    grid_zoom: int
    loop_videos: bool
    notebook_height: int
    plugins: t.Optional[JSON]
    show_confidence: bool
    show_index: bool
    show_label: bool
    show_skeletons: bool
    show_tooltip: bool
    sidebar_mode: SidebarMode
    theme: Theme
    timezone: t.Optional[str]
    use_frame_number: bool


@gql.type
class Query(fosa.AggregateQuery):

    aggregations = gql.field(resolver=aggregate_resolver)

    @gql.field
    def colorscale(self) -> t.Optional[t.List[t.List[int]]]:
        if fo.app_config.colorscale:
            return fo.app_config.get_colormap()

        return None

    @gql.field
    def config(self) -> AppConfig:
        config = fose.get_state().config
        d = config.serialize()
        d["timezone"] = fo.config.timezone
        return from_dict(AppConfig, d, config=Config(check_types=False))

    @gql.field
    def context(self) -> str:
        return focx._get_context()

    @gql.field
    def dev(self) -> bool:
        return foc.DEV_INSTALL or foc.RC_INSTALL

    @gql.field
    def do_not_track(self) -> bool:
        return fo.config.do_not_track

    dataset: Dataset = gql.field(resolver=Dataset.resolver)
    datasets: Connection[Dataset, str] = gql.field(
        resolver=get_paginator_resolver(
            Dataset, "created_at", DATASET_FILTER_STAGE, "datasets"
        )
    )

    @gql.field
    async def samples(
        self,
        dataset: str,
        view: BSONArray,
        first: t.Optional[int] = 20,
        after: t.Optional[str] = None,
        filter: t.Optional[SampleFilter] = None,
    ) -> Connection[SampleItem, str]:
        return await paginate_samples(
            dataset, view, None, first, after, sample_filter=filter
        )

    @gql.field
    async def sample(
        self, dataset: str, view: BSONArray, filter: SampleFilter
    ) -> t.Optional[SampleItem]:
        samples = await paginate_samples(
            dataset, view, None, 1, sample_filter=filter
        )
        if samples.edges:
            return samples.edges[0].node

        return None

    @gql.field
    def teams_submission(self) -> bool:
        isfile = os.path.isfile(foc.TEAMS_PATH)
        if isfile:
            submitted = etas.load_json(foc.TEAMS_PATH)["submitted"]
        else:
            submitted = False

        return submitted

    @gql.field
    def uid(self) -> str:
        uid, _ = fou.get_user_id()
        return uid

    @gql.field
    def version(self) -> str:
        return foc.VERSION

    @gql.field
    def saved_views(self, dataset_name: str) -> t.Optional[t.List[SavedView]]:
        ds = fo.load_dataset(dataset_name)
        return [
            SavedView.from_doc(view_doc) for view_doc in ds._doc.saved_views
        ]


def _flatten_fields(
    path: t.List[str], fields: t.List[t.Dict]
) -> t.List[t.Dict]:
    result = []
    for field in fields:
        key = field.pop("name")
        field_path = path + [key]
        field["path"] = ".".join(field_path)
        result.append(field)

        fields = field.pop("fields", None)
        if fields:
            result = result + _flatten_fields(field_path, fields)

    return result


def _convert_targets(targets: t.Dict[str, str]) -> t.List[Target]:
    return [Target(target=int(k), value=v) for k, v in targets.items()]


async def serialize_dataset(
    dataset_name: str, serialized_view: BSONArray, view_name: t.Optional[str]
) -> Dataset:
    def run():
        dataset = fo.load_dataset(dataset_name)
        dataset.reload()

        if view_name is not None and dataset.has_saved_view(view_name):
            view = dataset.load_saved_view(view_name)
        else:
            view = fov.DatasetView._build(dataset, serialized_view or [])

        doc = dataset._doc.to_dict(no_dereference=True)
        Dataset.modifier(doc)
        data = from_dict(Dataset, doc, config=Config(check_types=False))
        data.view_cls = None

        collection = dataset.view()
        if view is not None:
            if view._dataset != dataset:
                d = view._dataset._serialize()
                data.media_type = d["media_type"]

                data.id = view._dataset._doc.id

                data.view_cls = etau.get_class_name(view)

            if view.media_type != data.media_type:
                data.id = ObjectId()
                data.media_type = view.media_type

            collection = view

        data.sample_fields = serialize_fields(
            collection.get_field_schema(flat=True)
        )
        data.frame_fields = serialize_fields(
            collection.get_frame_field_schema(flat=True)
        )

        if dataset.media_type == fom.GROUP:
            data.group_slice = collection.group_slice

        return data

    loop = asyncio.get_running_loop()

    return await loop.run_in_executor(None, run)

fiftyone/server/query.py
"""
FiftyOne Server queries

| Copyright 2017-2022, Voxel51, Inc.
| `voxel51.com <https://voxel51.com/>`_
|
"""
import typing as t
from dataclasses import asdict
from datetime import date, datetime
from enum import Enum
import os

import asyncio
import eta.core.serial as etas
import eta.core.utils as etau
import strawberry as gql
from bson import ObjectId, json_util
from dacite import Config, from_dict

import fiftyone as fo
import fiftyone.constants as foc
import fiftyone.core.context as focx
import fiftyone.core.media as fom
from fiftyone.core.odm import SavedViewDocument
from fiftyone.core.state import SampleField, serialize_fields
import fiftyone.core.uid as fou
import fiftyone.core.view as fov

import fiftyone.server.aggregate as fosa
from fiftyone.server.aggregations import aggregate_resolver
from fiftyone.server.data import Info
from fiftyone.server.dataloader import get_dataloader_resolver
import fiftyone.server.events as fose
from fiftyone.server.metadata import MediaType
from fiftyone.server.paginator import Connection, get_paginator_resolver
from fiftyone.server.samples import (
    SampleFilter,
    SampleItem,
    paginate_samples,
)

from fiftyone.server.scalars import BSONArray, JSON

ID = gql.scalar(
    t.NewType("ID", str),
    serialize=lambda v: str(v),
    parse_value=lambda v: ObjectId(v),
)
DATASET_FILTER = [{"sample_collection_name": {"$regex": "^samples\\."}}]
DATASET_FILTER_STAGE = [{"$match": DATASET_FILTER[0]}]


@gql.type
class Group:
    name: str
    media_type: MediaType


@gql.type
class Target:
    target: int
    value: str


@gql.type
class NamedTargets:
    name: str
    targets: t.List[Target]


@gql.interface
class RunConfig:
    cls: str


@gql.interface
class Run:
    key: str
    version: t.Optional[str]
    timestamp: t.Optional[datetime]
    config: t.Optional[RunConfig]
    view_stages: t.Optional[t.List[str]]


@gql.type
class BrainRunConfig(RunConfig):
    embeddings_field: t.Optional[str]
    method: t.Optional[str]
    patches_field: t.Optional[str]


@gql.type
class BrainRun(Run):
    config: t.Optional[BrainRunConfig]


@gql.type
class EvaluationRunConfig(RunConfig):
    gt_field: t.Optional[str]
    pred_field: t.Optional[str]
    method: t.Optional[str]


@gql.type
class EvaluationRun(Run):
    config: t.Optional[EvaluationRunConfig]


@gql.type
class SavedView:
    id: t.Optional[str]
    dataset_id: t.Optional[str]
    name: t.Optional[str]
    slug: t.Optional[str]
    description: t.Optional[str]
    color: t.Optional[str]
    view_stages: t.Optional[t.List[str]]
    created_at: t.Optional[datetime]
    last_modified_at: t.Optional[datetime]
    last_loaded_at: t.Optional[datetime]

    @gql.field
    def view_name(self) -> t.Optional[str]:
        if isinstance(self, ObjectId):
            return None
        return self.name

    @gql.field
    def stage_dicts(self) -> t.Optional[BSONArray]:
        return [json_util.loads(x) for x in self.view_stages]

    @classmethod
    def from_doc(cls, doc: SavedViewDocument):
        stage_dicts = [json_util.loads(x) for x in doc.view_stages]
        saved_view = from_dict(data_class=cls, data=doc.to_dict())
        saved_view.stage_dicts = stage_dicts
        return saved_view


@gql.type
class SidebarGroup:
    name: str
    paths: t.Optional[t.List[str]]
    expanded: t.Optional[bool] = None


@gql.type
class KeypointSkeleton:
    labels: t.Optional[t.List[str]]
    edges: t.List[t.List[int]]


@gql.type
class NamedKeypointSkeleton(KeypointSkeleton):
    name: str


@gql.enum
class SidebarMode(Enum):
    all = "all"
    best = "best"
    fast = "fast"


@gql.type
class DatasetAppConfig:
    media_fields: t.Optional[t.List[str]]
    plugins: t.Optional[JSON]
    sidebar_groups: t.Optional[t.List[SidebarGroup]]
    sidebar_mode: t.Optional[SidebarMode]
    modal_media_field: t.Optional[str] = gql.field(default="filepath")
    grid_media_field: t.Optional[str] = "filepath"


@gql.type
class Dataset:
    id: gql.ID
    name: str
    created_at: t.Optional[date]
    last_loaded_at: t.Optional[datetime]
    persistent: bool
    group_media_types: t.Optional[t.List[Group]]
    group_field: t.Optional[str]
    group_slice: t.Optional[str]
    default_group_slice: t.Optional[str]
    media_type: t.Optional[MediaType]
    mask_targets: t.List[NamedTargets]
    default_mask_targets: t.Optional[t.List[Target]]
    sample_fields: t.List[SampleField]
    frame_fields: t.Optional[t.List[SampleField]]
    brain_methods: t.Optional[t.List[BrainRun]]
    evaluations: t.Optional[t.List[EvaluationRun]]
    saved_views: t.Optional[t.List[SavedView]]
    saved_view_ids: gql.Private[t.Optional[t.List[gql.ID]]]
    version: t.Optional[str]
    view_cls: t.Optional[str]
    view_name: t.Optional[str]
    default_skeleton: t.Optional[KeypointSkeleton]
    skeletons: t.List[NamedKeypointSkeleton]
    app_config: t.Optional[DatasetAppConfig]
    info: t.Optional[JSON]

    @staticmethod
    def modifier(doc: dict) -> dict:
        doc["id"] = doc.pop("_id")
        doc["default_mask_targets"] = _convert_targets(
            doc.get("default_mask_targets", {})
        )
        doc["mask_targets"] = [
            NamedTargets(name=name, targets=_convert_targets(targets))
            for name, targets in doc.get("mask_targets", {}).items()
        ]
        doc["sample_fields"] = _flatten_fields(
            [], doc.get("sample_fields", [])
        )
        doc["frame_fields"] = _flatten_fields([], doc.get("frame_fields", []))
        doc["brain_methods"] = list(doc.get("brain_methods", {}).values())
        doc["evaluations"] = list(doc.get("evaluations", {}).values())
        doc["saved_views"] = doc.get("saved_views", [])
        doc["skeletons"] = list(
            dict(name=name, **data)
            for name, data in doc.get("skeletons", {}).items()
        )
        doc["group_media_types"] = [
            Group(name=name, media_type=media_type)
            for name, media_type in doc.get("group_media_types", {}).items()
        ]
        doc["default_skeletons"] = doc.get("default_skeletons", None)
        return doc

    @classmethod
    async def resolver(
        cls,
        name: str,
        view: t.Optional[BSONArray],
        info: Info,
        view_name: t.Optional[str] = gql.UNSET,
    ) -> t.Optional["Dataset"]:
        return await serialize_dataset(
            dataset_name=name, serialized_view=view, view_name=view_name
        )


dataset_dataloader = get_dataloader_resolver(
    Dataset, "datasets", "name", DATASET_FILTER
)


@gql.enum
class ColorBy(Enum):
    field = "field"
    instance = "instance"
    label = "label"


@gql.enum
class Theme(Enum):
    browser = "browser"
    dark = "dark"
    light = "light"


@gql.type
class AppConfig:
    color_by: ColorBy
    color_pool: t.List[str]
    colorscale: str
    grid_zoom: int
    loop_videos: bool
    notebook_height: int
    plugins: t.Optional[JSON]
    show_confidence: bool
    show_index: bool
    show_label: bool
    show_skeletons: bool
    show_tooltip: bool
    sidebar_mode: SidebarMode
    theme: Theme
    timezone: t.Optional[str]
    use_frame_number: bool


@gql.type
class Query(fosa.AggregateQuery):

    aggregations = gql.field(resolver=aggregate_resolver)

    @gql.field
    def colorscale(self) -> t.Optional[t.List[t.List[int]]]:
        if fo.app_config.colorscale:
            return fo.app_config.get_colormap()

        return None

    @gql.field
    def config(self) -> AppConfig:
        config = fose.get_state().config
        d = config.serialize()
        d["timezone"] = fo.config.timezone
        return from_dict(AppConfig, d, config=Config(check_types=False))

    @gql.field
    def context(self) -> str:
        return focx._get_context()

    @gql.field
    def dev(self) -> bool:
        return foc.DEV_INSTALL or foc.RC_INSTALL

    @gql.field
    def do_not_track(self) -> bool:
        return fo.config.do_not_track

    dataset: Dataset = gql.field(resolver=Dataset.resolver)
    datasets: Connection[Dataset, str] = gql.field(
        resolver=get_paginator_resolver(
            Dataset, "created_at", DATASET_FILTER_STAGE, "datasets"
        )
    )

    @gql.field
    async def samples(
        self,
        dataset: str,
        view: BSONArray,
        first: t.Optional[int] = 20,
        after: t.Optional[str] = None,
        filter: t.Optional[SampleFilter] = None,
    ) -> Connection[SampleItem, str]:
        return await paginate_samples(
            dataset, view, None, first, after, sample_filter=filter
        )

    @gql.field
    async def sample(
        self, dataset: str, view: BSONArray, filter: SampleFilter
    ) -> t.Optional[SampleItem]:
        samples = await paginate_samples(
            dataset, view, None, 1, sample_filter=filter
        )
        if samples.edges:
            return samples.edges[0].node

        return None

    @gql.field
    def teams_submission(self) -> bool:
        isfile = os.path.isfile(foc.TEAMS_PATH)
        if isfile:
            submitted = etas.load_json(foc.TEAMS_PATH)["submitted"]
        else:
            submitted = False

        return submitted

    @gql.field
    def uid(self) -> str:
        uid, _ = fou.get_user_id()
        return uid

    @gql.field
    def version(self) -> str:
        return foc.VERSION

    @gql.field
    def saved_views(self, dataset_name: str) -> t.Optional[t.List[SavedView]]:
        ds = fo.load_dataset(dataset_name)
        return [
            SavedView.from_doc(view_doc) for view_doc in ds._doc.saved_views
        ]


def _flatten_fields(
    path: t.List[str], fields: t.List[t.Dict]
) -> t.List[t.Dict]:
    result = []
    for field in fields:
        key = field.pop("name")
        field_path = path + [key]
        field["path"] = ".".join(field_path)
        result.append(field)

        fields = field.pop("fields", None)
        if fields:
            result = result + _flatten_fields(field_path, fields)

    return result


def _convert_targets(targets: t.Dict[str, str]) -> t.List[Target]:
    return [Target(target=int(k), value=v) for k, v in targets.items()]


async def serialize_dataset(
    dataset_name: str, serialized_view: BSONArray, view_name: t.Optional[str]
) -> Dataset:
    def run():
        dataset = fo.load_dataset(dataset_name)
        dataset.reload()

        if view_name is not None and dataset.has_saved_view(view_name):
            view = dataset.load_saved_view(view_name)
        else:
            view = fov.DatasetView._build(dataset, serialized_view or [])

        doc = dataset._doc.to_dict(no_dereference=True)
        Dataset.modifier(doc)
        data = from_dict(Dataset, doc, config=Config(check_types=False))
        data.view_cls = None

        collection = dataset.view()
        if view is not None:
            if view._dataset != dataset:
                d = view._dataset._serialize()
                data.media_type = d["media_type"]

                data.id = view._dataset._doc.id

                data.view_cls = etau.get_class_name(view)

            if view.media_type != data.media_type:
                data.id = ObjectId()
                data.media_type = view.media_type

            collection = view

        data.sample_fields = serialize_fields(
            collection.get_field_schema(flat=True)
        )
        data.frame_fields = serialize_fields(
            collection.get_frame_field_schema(flat=True)
        )

        if dataset.media_type == fom.GROUP:
            data.group_slice = collection.group_slice

        return data

    loop = asyncio.get_running_loop()

    return await loop.run_in_executor(None, run)

fiftyone/server/query.py
diff --git a/app/packages/state/src/recoil/sidebar.ts b/app/packages/state/src/recoil/sidebar.ts
index 5ee80315083..bc9a0f62d6a 100644
--- a/app/packages/state/src/recoil/sidebar.ts
+++ b/app/packages/state/src/recoil/sidebar.ts
@@ -212,11 +212,17 @@ const DEFAULT_VIDEO_GROUPS = [
   { name: "other", paths: [] },
 ];
 
+const NONE = [null, undefined];
+
 export const resolveGroups = (
   dataset: State.Dataset,
   current?: State.SidebarGroup[]
 ): State.SidebarGroup[] => {
-  let groups = JSON.parse(JSON.stringify(dataset?.appConfig?.sidebarGroups));
+  const sidebarGroups = dataset?.appConfig?.sidebarGroups;
+
+  let groups = sidebarGroups
+    ? JSON.parse(JSON.stringify(sidebarGroups))
+    : undefined;
 
   const expanded = current
     ? current.reduce((map, { name, expanded }) => {
@@ -366,16 +372,16 @@ export const sidebarGroups = selectorFamily<
         if (
           video &&
           groups[framesIndex] &&
-          groups[framesIndex].expanded === undefined
+          NONE.includes(groups[framesIndex].expanded)
         ) {
           groups[framesIndex].expanded = !largeVideo;
         }
 
-        if (groups[tagsIndex].expanded === undefined) {
+        if (NONE.includes(groups[tagsIndex].expanded)) {
           groups[tagsIndex].expanded =
             get(resolvedSidebarMode(false)) === "all";
         }
-        if (groups[labelTagsIndex].expanded === undefined) {
+        if (NONE.includes(groups[labelTagsIndex].expanded)) {
           groups[labelTagsIndex].expanded =
             get(resolvedSidebarMode(false)) === "all" ? !largeVideo : false;
         }
@@ -399,19 +405,19 @@ export const sidebarGroups = selectorFamily<
             .filter((tag) => !filtered || tag.includes(f))
             .map((tag) => `_label_tags.${tag}`));
       } else {
-        if (groups[labelTagsIndex].expanded === undefined) {
+        if (NONE.includes(groups[labelTagsIndex].expanded)) {
           groups[labelTagsIndex].expanded = false;
         }
 
         if (
           video &&
           groups[framesIndex] &&
-          groups[framesIndex].expanded === undefined
+          NONE.includes(groups[framesIndex].expanded)
         ) {
           groups[framesIndex].expanded = false;
         }
 
-        if (groups[tagsIndex].expanded === undefined) {
+        if (NONE.includes(groups[tagsIndex].expanded)) {
           groups[tagsIndex].expanded = false;
         }
       }
@@ -479,7 +485,7 @@ export const sidebarGroups = selectorFamily<
       !modal &&
         persist &&
         persistSidebarGroups({
-          subscription: useRecoilValue(stateSubscription),
+          subscription: get(stateSubscription),
           dataset: get(datasetName),
           stages: get(viewAtoms.view),
           sidebarGroups: groups,
@@ -633,6 +639,42 @@ export const disabledPaths = selector<Set<string>>({
   },
 });
 
+const collapsedPaths = selector<Set<string>>({
+  key: "collapsedPaths",
+  get: ({ get }) => {
+    let paths = [...get(fieldPaths({ ftype: DICT_FIELD }))];
+    paths = [...paths, ...get(fieldPaths({ ftype: LIST_FIELD }))];
+
+    get(
+      fields({ ftype: EMBEDDED_DOCUMENT_FIELD, space: State.SPACE.SAMPLE })
+    ).forEach(({ fields, name: prefix }) => {
+      Object.values(fields)
+        .filter(
+          ({ ftype, subfield }) =>
+            ftype === DICT_FIELD ||
+            subfield === DICT_FIELD ||
+            (ftype === LIST_FIELD && !subfield)
+        )
+        .forEach(({ name }) => paths.push(`${prefix}.${name}`));
+    });
+
+    get(fields({ space: State.SPACE.FRAME })).forEach(
+      ({ name, embeddedDocType }) => {
+        if (LABELS.includes(embeddedDocType)) {
+          return;
+        }
+
+        paths.push(`frames.${name}`);
+      }
+    );
+
+    return new Set(paths);
+  },
+  cachePolicy_UNSTABLE: {
+    eviction: "most-recent",
+  },
+});
+
 export const sidebarGroupMapping = selectorFamily<
   { [name: string]: Omit<State.SidebarGroup, "name"> },
   { modal: boolean; loading: boolean; filtered?: boolean }
@@ -709,12 +751,17 @@ export const groupShown = selectorFamily<
     ({ get }) => {
       const data = get(sidebarGroupMapping({ modal, loading }))[group];
 
-      return data.expanded === undefined
-        ? !["tags", "label tags"].includes(group)
-          ? !data.paths.length ||
-            !data.paths.every((path) => get(disabledPaths).has(path))
-          : true
-        : data.expanded;
+      if ([null, undefined].includes(data.expanded)) {
+        if (["tags", "label tags"].includes(group)) {
+          return null;
+        }
+        return (
+          !data.paths.length ||
+          !data.paths.every((path) => get(collapsedPaths).has(path))
+        );
+      }
+
+      return data.expanded;
     },
   set:
     ({ modal, group }) =>
diff --git a/app/packages/state/src/recoil/types.ts b/app/packages/state/src/recoil/types.ts
index a0c4afa0379..174f1b135d4 100644
--- a/app/packages/state/src/recoil/types.ts
+++ b/app/packages/state/src/recoil/types.ts
@@ -122,12 +122,12 @@ export namespace State {
     sampleFields: StrictField[];
     version: string;
     skeletons: StrictKeypointSkeleton[];
-    defaultSkeleton: KeypointSkeleton;
+    defaultSkeleton?: KeypointSkeleton;
     groupMediaTypes?: {
       name: string;
       mediaType: MediaType;
     }[];
-    defaultGroupSlice: string;
+    defaultGroupSlice?: string;
     groupField: string;
     appConfig: DatasetAppConfig;
     info: { [key: string]: string };
diff --git a/app/packages/state/tests/recoil/sidebar.test.ts b/app/packages/state/tests/recoil/sidebar.test.ts
new file mode 100644
index 00000000000..e2a55234701
--- /dev/null
+++ b/app/packages/state/tests/recoil/sidebar.test.ts
@@ -0,0 +1,271 @@
+import { describe, expect, it, vi } from "vitest";
+import * as sidebar from "../../src/recoil/sidebar";
+import { State } from "../../src/recoil/types";
+
+const mockDataset: State.Dataset = {
+  id: "mockQuickStart",
+  appConfig: {
+    gridMediaField: "filepath",
+    mediaFields: ["filepath"],
+    modalMediaField: "filepath",
+    plugins: {},
+  },
+  brainMethods: [],
+  createdAt: { $date: new Date().getTime() },
+  defaultMaskTargets: {},
+  evaluations: [],
+  frameFields: [],
+  lastLoadedAt: { $date: new Date().getTime() },
+  maskTargets: {},
+  mediaType: "image",
+  name: "quickstart-test",
+  sampleFields: [
+    {
+      dbField: "_id",
+      description: null,
+      embeddedDocType: null,
+      fields: [],
+      ftype: "fiftyone.core.fields.ObjectIdField",
+      info: null,
+      name: "id",
+      path: "id",
+      subfield: null,
+    },
+    {
+      dbField: "filepath",
+      description: null,
+      embeddedDocType: null,
+      fields: [],
+      ftype: "fiftyone.core.fields.StringField",
+      info: null,
+      name: "filepath",
+      path: "filepath",
+      subfield: null,
+    },
+    {
+      dbField: "tags",
+      description: null,
+      embeddedDocType: null,
+      fields: [],
+      ftype: "fiftyone.core.fields.ListField",
+      info: null,
+      name: "tags",
+      path: "tags",
+      subfield: "fiftyone.core.fields.StringField",
+    },
+    {
+      dbField: "metadata",
+      description: null,
+      embeddedDocType: "fiftyone.core.metadata.Metadata",
+      fields: [
+        {
+          dbField: "size_types",
+          description: null,
+          embeddedDocType: null,
+          fields: [],
+          ftype: "fiftyone.core.fields.IntField",
+          info: null,
+          name: "size_types",
+          subfield: null,
+          path: "metadata.size_types",
+        },
+        {
+          dbField: "mime_type",
+          description: null,
+          embeddedDocType: null,
+          fields: [],
+          ftype: "fiftyone.core.fields.StringField",
+          info: null,
+          name: "mime_type",
+          subfield: null,
+          path: "metadata.mime_type",
+        },
+        {
+          dbField: "width",
+          description: null,
+          embeddedDocType: null,
+          fields: [],
+          ftype: "fiftyone.core.fields.IntField",
+          info: null,
+          name: "width",
+          subfield: null,
+          path: "metadata.width",
+        },
+        {
+          dbField: "height",
+          description: null,
+          embeddedDocType: null,
+          fields: [],
+          ftype: "fiftyone.core.fields.IntField",
+          info: null,
+          name: "height",
+          subfield: null,
+          path: "metadata.height",
+        },
+        {
+          dbField: "num_channels",
+          description: null,
+          embeddedDocType: null,
+          fields: [],
+          ftype: "fiftyone.core.fields.IntField",
+          info: null,
+          name: "num_channels",
+          subfield: null,
+          path: "metadata.num_channels",
+        },
+      ],
+      ftype: "fiftyone.core.fields.EmbeddedDocumentField",
+      info: null,
+      name: "metadata",
+      path: "metadata",
+      subfield: null,
+    },
+    {
+      dbField: "ground_truth",
+      description: null,
+      embeddedDocType: "fiftyone.core.labels.Detection",
+      fields: [
+        {
+          dbField: "detections",
+          description: null,
+          embeddedDocType: null,
+          fields: [],
+          ftype: "fiftyone.core.fields.ListField",
+          info: null,
+          name: "detections",
+          subfield: "fiftyone.core.fields.EmbeddedDocumentField",
+          path: "ground_truth.detections",
+        },
+      ],
+      ftype: "fiftyone.core.fields.EmbeddedDocumentField",
+      info: null,
+      name: "ground_truth",
+      path: "ground_truth",
+      subfield: null,
+    },
+    {
+      dbField: "uniqueness",
+      description: null,
+      embeddedDocType: null,
+      fields: [],
+      ftype: "fiftyone.core.fields.FloatField",
+      info: null,
+      name: "uniqueness",
+      subfield: null,
+      path: "uniqueness",
+    },
+    {
+      dbField: "predictions",
+      description: null,
+      embeddedDocType: "fiftyone.core.labels.Detection",
+      fields: [
+        {
+          dbField: "detections",
+          description: null,
+          embeddedDocType: null,
+          fields: [],
+          ftype: "fiftyone.core.fields.ListField",
+          info: null,
+          name: "detections",
+          subfield: "fiftyone.core.fields.EmbeddedDocumentField",
+          path: "ground_truth.detections",
+        },
+      ],
+      ftype: "fiftyone.core.fields.EmbeddedDocumentField",
+      info: null,
+      name: "predictions",
+      subfield: null,
+      path: "predictions",
+    },
+    {
+      dbField: "dict_field",
+      description: null,
+      embeddedDocType: null,
+      fields: [],
+      ftype: "fiftyone.core.fields.DictField",
+      info: null,
+      name: "dict_field",
+      subfield: null,
+      path: "dict_field",
+    },
+    {
+      dbField: "list_field",
+      description: null,
+      embeddedDocType: null,
+      fields: [],
+      ftype: "fiftyone.core.fields.ListField",
+      info: null,
+      name: "list_field",
+      subfield: null,
+      path: "list_field",
+    },
+  ],
+  version: "test",
+  skeletons: [],
+  groupMediaTypes: [],
+  groupField: "",
+  info: {},
+};
+
+describe("ResolveGroups works", () => {
+  it("dataset groups should resolve when curent is undefined", () => {
+    const test = sidebar.resolveGroups(mockDataset, undefined);
+
+    expect(test.length).toBe(6);
+    expect(test[0].name).toBe("tags");
+    expect(test[0].paths.length).toBe(0);
+    expect(test[1].name).toBe("label tags");
+    expect(test[1].paths.length).toBe(0);
+    expect(test[2].name).toBe("metadata");
+    expect(test[2].paths.length).toBe(5);
+    expect(test[3].name).toBe("labels");
+    expect(test[3].paths.length).toBe(2);
+    expect(test[4].name).toBe("primitives");
+    expect(test[4].paths.length).toBe(3);
+    expect(test[5].name).toBe("other");
+    expect(test[5].paths.length).toBe(2);
+  });
+
+  it("when dataset appconfig does not have sidebarGroups settings, use default settings", () => {
+    const mockSidebarGroups = [
+      { name: "tags", paths: [], expanded: true },
+      { name: "label tags", paths: [], expanded: true },
+      {
+        name: "metadata",
+        paths: [
+          "metadata.size_types",
+          "metadata.mime_type",
+          "metadata.width",
+          "metadata.height",
+          "metadata.num_channels",
+        ],
+        expanded: true,
+      },
+      {
+        name: "labels",
+        paths: ["ground_truth", "predictions"],
+        expanded: true,
+      },
+      {
+        name: "primitives",
+        paths: ["id", "filepath", "uniqueness"],
+        expanded: true,
+      },
+      { name: "other", paths: ["dict_field", "list_field"] },
+      { name: "test group a", paths: [] },
+      { name: "test group b", paths: [] },
+    ];
+    const dataSetWithNewGroups = { ...mockDataset };
+    dataSetWithNewGroups.appConfig.sidebarGroups = mockSidebarGroups;
+
+    const test = sidebar.resolveGroups(mockDataset, mockSidebarGroups);
+
+    expect(test.length).toBe(8);
+    expect(test[5].name).toBe("other");
+    expect(test[5].expanded).toBeFalsy();
+    expect(test[6].name).toBe("test group a");
+    expect(test[6].expanded).toBeFalsy();
+    expect(test[7].name).toBe("test group b");
+    expect(test[7].expanded).toBeFalsy();
+  });
+});
diff --git a/app/packages/utilities/src/index.ts b/app/packages/utilities/src/index.ts
index 148636651c8..fac8ba5973b 100644
--- a/app/packages/utilities/src/index.ts
+++ b/app/packages/utilities/src/index.ts
@@ -92,6 +92,7 @@ interface BaseField {
   name: string;
   embeddedDocType: string | null;
   subfield: string | null;
+  path: string | null;
 }
 
 export interface StrictField extends BaseField {
diff --git a/fiftyone/server/query.py b/fiftyone/server/query.py
index 20a355819ca..17af3c7408e 100644
--- a/fiftyone/server/query.py
+++ b/fiftyone/server/query.py
@@ -142,7 +142,7 @@ def from_doc(cls, doc: SavedViewDocument):
 class SidebarGroup:
     name: str
     paths: t.Optional[t.List[str]]
-    expanded: t.Optional[bool] = True
+    expanded: t.Optional[bool] = None
 
 
 @gql.type

[BUG] Use same default expansion logic when sidebar groups are defined
As of `fiftyone==0.18`, the sidebar has some nice default logic, such as automatically collapsing the `OTHER` group for the dataset below, since it contains all unsupported field types:

```py
import fiftyone as fo
import fiftyone.zoo as foz

dataset = foz.load_zoo_dataset("quickstart")

dataset.set_values("dict_field", [{}] * len(dataset))

dataset.add_sample_field("list_field", fo.ListField)
dataset.set_values("list_field", dataset.values("tags"))

session = fo.launch_app(dataset)
```

Or collapsing the sample/label tags sections by default in fast mode.

However, this default expansion logic only applies when the dataset does not have an `app_config` explicitly defined. Once an app config is defined, the collapsed-by-default logic no longer works.

To see this, make a trivial edit to the sidebar groups in the App and then refresh the page.

```py
# Edit sidebar groups in the App

dataset.reload()
print(dataset.app_config)
```

```
<DatasetAppConfig: {
    'media_fields': ['filepath'],
    'grid_media_field': 'filepath',
    'modal_media_field': 'filepath',
    'sidebar_mode': None,
    'sidebar_groups': [
        <SidebarGroupDocument: {'name': 'tags', 'paths': [], 'expanded': None}>,
        <SidebarGroupDocument: {'name': 'label tags', 'paths': [], 'expanded': None}>,
        <SidebarGroupDocument: {
            'name': 'metadata',
            'paths': [
                'metadata.size_bytes',
                'metadata.mime_type',
                'metadata.width',
                'metadata.height',
                'metadata.num_channels',
            ],
            'expanded': None,
        }>,
        <SidebarGroupDocument: {'name': 'labels', 'paths': ['predictions', 'ground_truth'], 'expanded': None}>,
        <SidebarGroupDocument: {'name': 'primitives', 'paths': ['id', 'uniqueness', 'filepath'], 'expanded': None}>,
        <SidebarGroupDocument: {'name': 'other', 'paths': ['dict_field', 'list_field'], 'expanded': None}>,
    ],
    'plugins': {},
}>
```

In the above `sidebar_groups`, all `expanded` states are `None`, so the default logic should be applied to determine whether they are collapsed or not.
