Skip to content
Merged
688 changes: 568 additions & 120 deletions apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx

Large diffs are not rendered by default.

874 changes: 701 additions & 173 deletions apps/app/src/components/plugin/PluginNavSidebarItems.tsx

Large diffs are not rendered by default.

178 changes: 178 additions & 0 deletions apps/app/src/components/plugin/pluginNavSidebarAtoms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// @vitest-environment jsdom

import { createStore } from "jotai";
import { afterEach, describe, expect, it, vi } from "vitest";

afterEach(() => {
window.localStorage.clear();
vi.resetModules();
});

describe("pluginNavPanelOrderAtom migration", () => {
it("converts legacy hidden keys into positional overflow order", async () => {
window.localStorage.setItem(
"bb.sidebar.pluginPanelOrder",
JSON.stringify(["docs/main", "tasks/main", "github/main"]),
);
window.localStorage.setItem(
"bb.sidebar.hiddenPluginPanels",
JSON.stringify(["tasks/main", "docs/main"]),
);

const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } =
await import("./pluginNavSidebarAtoms");
const store = createStore();

expect(store.get(pluginNavPanelOrderAtom)).toEqual([
"github/main",
"docs/main",
"tasks/main",
]);
expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([
"github/main",
]);
expect(
window.localStorage.getItem("bb.sidebar.hiddenPluginPanels"),
).toBeNull();
});

it("migrates visibility when it is read before order", async () => {
window.localStorage.setItem(
"bb.sidebar.pluginPanelOrder",
JSON.stringify(["docs/main", "tasks/main", "github/main"]),
);
window.localStorage.setItem(
"bb.sidebar.hiddenPluginPanels",
JSON.stringify(["tasks/main"]),
);

const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } =
await import("./pluginNavSidebarAtoms");
const store = createStore();

expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([
"docs/main",
"github/main",
]);
expect(store.get(pluginNavPanelOrderAtom)).toEqual([
"docs/main",
"github/main",
"tasks/main",
]);
});

it("migrates hidden Automations into the unified visibility preference", async () => {
window.localStorage.setItem(
"bb.sidebar.pluginPanelOrder",
JSON.stringify(["docs/main"]),
);
window.localStorage.setItem(
"bb.sidebar.hiddenPluginPanels",
JSON.stringify(["docs/main", "automations/main"]),
);

const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } =
await import("./pluginNavSidebarAtoms");
const store = createStore();

expect(store.get(pluginNavPanelOrderAtom)).toEqual([
"docs/main",
"__bb__/automations",
]);
expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([]);
expect(
window.localStorage.getItem("bb.sidebar.hiddenPluginPanels"),
).toBeNull();
});

it("maps legacy built-in order and visibility keys to unified keys", async () => {
window.localStorage.setItem(
"bb.sidebar.pluginPanelOrder",
JSON.stringify(["__builtin__/tools", "automations/main", "docs/main"]),
);
window.localStorage.setItem(
"bb.sidebar.visiblePluginPanels",
JSON.stringify(["automations/main", "__builtin__/tools"]),
);

const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } =
await import("./pluginNavSidebarAtoms");
const store = createStore();

expect(store.get(pluginNavPanelOrderAtom)).toEqual([
"__bb__/extensions",
"__bb__/automations",
"docs/main",
]);
expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([
"__bb__/automations",
"__bb__/extensions",
]);
});

it("keeps fresh visibility unset so defaults can follow available rows", async () => {
const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } =
await import("./pluginNavSidebarAtoms");
const store = createStore();

expect(store.get(pluginNavPanelOrderAtom)).toEqual([]);
expect(store.get(pluginNavVisiblePanelKeysAtom)).toBeNull();
});

it("does not overwrite an existing visibility preference during migration", async () => {
window.localStorage.setItem(
"bb.sidebar.pluginPanelOrder",
JSON.stringify(["docs/main", "tasks/main", "github/main"]),
);
window.localStorage.setItem(
"bb.sidebar.visiblePluginPanels",
JSON.stringify(["tasks/main"]),
);
window.localStorage.setItem(
"bb.sidebar.hiddenPluginPanels",
JSON.stringify(["docs/main"]),
);

const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } =
await import("./pluginNavSidebarAtoms");
const store = createStore();

expect(store.get(pluginNavPanelOrderAtom)).toEqual([
"tasks/main",
"github/main",
"docs/main",
]);
expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual(["tasks/main"]);
});

it("persists an explicit empty visible list", async () => {
const { pluginNavVisiblePanelKeysAtom } = await import(
"./pluginNavSidebarAtoms"
);
const store = createStore();

store.set(pluginNavVisiblePanelKeysAtom, []);

expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([]);
expect(
window.localStorage.getItem("bb.sidebar.visiblePluginPanels"),
).toBe("[]");
});

it("normalizes persisted visible keys without discarding unregistered keys", async () => {
window.localStorage.setItem(
"bb.sidebar.visiblePluginPanels",
JSON.stringify(["docs/main", "future/main", "docs/main"]),
);

const { pluginNavVisiblePanelKeysAtom } = await import(
"./pluginNavSidebarAtoms"
);
const store = createStore();

expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([
"docs/main",
"future/main",
]);
});
});
141 changes: 135 additions & 6 deletions apps/app/src/components/plugin/pluginNavSidebarAtoms.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,148 @@
import { atomWithStorage } from "jotai/utils";
import { createJsonLocalStorage } from "@/lib/browser-storage";
import {
createJsonLocalStorage,
type SyncStorage,
} from "@/lib/browser-storage";
import { AUTOMATIONS_PLUGIN_ID } from "@/lib/route-paths";
import {
BUILT_IN_SIDEBAR_NAVIGATION_KEYS,
migrateLegacyHiddenPluginNavPanelOrder,
} from "./pluginNavSidebarOrder";

const PLUGIN_NAV_PANEL_ORDER_STORAGE_KEY = "bb.sidebar.pluginPanelOrder";
const VISIBLE_PLUGIN_NAV_PANELS_STORAGE_KEY =
"bb.sidebar.visiblePluginPanels";
const HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY = "bb.sidebar.hiddenPluginPanels";
const LEGACY_EXTENSIONS_NAV_PANEL_KEY = "__builtin__/tools";

function toSidebarNavigationKey(key: string): string {
if (key === LEGACY_EXTENSIONS_NAV_PANEL_KEY) {
return BUILT_IN_SIDEBAR_NAVIGATION_KEYS.extensions;
}
if (key.startsWith(`${AUTOMATIONS_PLUGIN_ID}/`)) {
return BUILT_IN_SIDEBAR_NAVIGATION_KEYS.automations;
}
return key;
}

function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [
...new Set(
value.filter((item): item is string => typeof item === "string"),
),
];
}

function normalizeSidebarNavigationKeys(value: unknown): string[] {
return [...new Set(normalizeStringArray(value).map(toSidebarNavigationKey))];
}

function normalizeVisiblePanelKeys(value: unknown): string[] | null {
return Array.isArray(value) ? normalizeSidebarNavigationKeys(value) : null;
}

function migrateLegacyPluginNavPreferences(
storage: SyncStorage<unknown>,
order: readonly string[],
): string[] {
const normalizedOrder = normalizeSidebarNavigationKeys(order);
const legacyHiddenValue = storage.getItem(
HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY,
null,
);
const legacyHidden = normalizeSidebarNavigationKeys(
legacyHiddenValue,
);
const migrated = migrateLegacyHiddenPluginNavPanelOrder(
normalizedOrder,
legacyHidden,
);
const storedVisibleKeys = normalizeVisiblePanelKeys(
storage.getItem(VISIBLE_PLUGIN_NAV_PANELS_STORAGE_KEY, null),
);
if (storedVisibleKeys === null && Array.isArray(legacyHiddenValue)) {
const hidden = new Set(legacyHidden);
storage.setItem(
VISIBLE_PLUGIN_NAV_PANELS_STORAGE_KEY,
migrated.filter((key) => !hidden.has(key)),
);
}
storage.setItem(PLUGIN_NAV_PANEL_ORDER_STORAGE_KEY, migrated);
storage.removeItem(HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY);
return migrated;
}

function createPluginNavPanelOrderStorage(): SyncStorage<string[]> {
const storage = createJsonLocalStorage<unknown>();
return {
getItem(key, initialValue) {
const order = normalizeSidebarNavigationKeys(
storage.getItem(key, initialValue),
);
return migrateLegacyPluginNavPreferences(storage, order);
},
setItem(key, value) {
storage.setItem(key, normalizeSidebarNavigationKeys(value));
},
removeItem(key) {
storage.removeItem(key);
},
subscribe: (key, callback, initialValue) =>
storage.subscribe?.(
key,
(value) => callback(normalizeSidebarNavigationKeys(value)),
initialValue,
),
};
}

function createPluginNavVisiblePanelKeysStorage(): SyncStorage<
string[] | null
> {
const storage = createJsonLocalStorage<unknown>();
return {
getItem(key, initialValue) {
const storedVisibleKeys = normalizeVisiblePanelKeys(
storage.getItem(key, initialValue),
);
if (storedVisibleKeys !== null) return storedVisibleKeys;

const order = normalizeSidebarNavigationKeys(
storage.getItem(PLUGIN_NAV_PANEL_ORDER_STORAGE_KEY, []),
);
migrateLegacyPluginNavPreferences(storage, order);
return normalizeVisiblePanelKeys(storage.getItem(key, initialValue));
},
setItem(key, value) {
if (value === null) {
storage.removeItem(key);
return;
}
storage.setItem(key, normalizeSidebarNavigationKeys(value));
},
removeItem(key) {
storage.removeItem(key);
},
subscribe: (key, callback, initialValue) =>
storage.subscribe?.(
key,
(value) => callback(normalizeVisiblePanelKeys(value)),
initialValue,
),
};
}

export const pluginNavPanelOrderAtom = atomWithStorage<string[]>(
PLUGIN_NAV_PANEL_ORDER_STORAGE_KEY,
[],
createJsonLocalStorage<string[]>(),
createPluginNavPanelOrderStorage(),
{ getOnInit: true },
);

export const hiddenPluginNavPanelsAtom = atomWithStorage<string[]>(
HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY,
[],
createJsonLocalStorage<string[]>(),
export const pluginNavVisiblePanelKeysAtom = atomWithStorage<string[] | null>(
VISIBLE_PLUGIN_NAV_PANELS_STORAGE_KEY,
null,
createPluginNavVisiblePanelKeysStorage(),
{ getOnInit: true },
);
Loading