Repository: voxel51/fiftyone

Issue:
[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.


Discussion:
Looks like `dataset.appConfig` is not in sync with the backend setting. 
![Screenshot 2022-12-06 at 11 59 23 PM](https://user-images.githubusercontent.com/17770824/206100379-d5054a62-7b91-49e9-95fe-3fc2788d4621.png)
![Screenshot 2022-12-06 at 11 59 14 PM](https://user-images.githubusercontent.com/17770824/206100382-7f7bf6ca-49f1-4ffa-b4df-4b2bd4db3407.png)

There is some logic in-place that will populate default values clientside using the `dataset.app_config` from the server for defaults. May need to discuss offline to explain that.

Resolving patch:
diff --git a/fiftyone/server/query.py b/fiftyone/server/query.py
--- 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


Test patch:
diff --git a/app/packages/state/tests/recoil/sidebar.test.ts b/app/packages/state/tests/recoil/sidebar.test.ts
new file mode 100644
--- /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();
+  });
+});