Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/web/src/atoms/sidebar-preferences-atoms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @vitest-environment jsdom

import { describe, expect, it } from "vitest"
import {
LOCKED_NAV_IDS,
applySidebarPrefs,
defaultSidebarPrefs,
mergeOrder,
type SidebarPrefs,
} from "./sidebar-preferences-atoms"
import { visibleSignalsNavItems } from "@/components/dashboard/nav-items"

const prefs = (overrides: Partial<SidebarPrefs>): SidebarPrefs => ({ ...defaultSidebarPrefs, ...overrides })

describe("mergeOrder", () => {
it("returns defaults when nothing is stored", () => {
expect(mergeOrder(["a", "b", "c"], [])).toEqual(["a", "b", "c"])
})

it("round-trips a full permutation", () => {
expect(mergeOrder(["a", "b", "c"], ["c", "a", "b"])).toEqual(["c", "a", "b"])
})

it("drops stored ids that no longer exist", () => {
expect(mergeOrder(["a", "b"], ["b", "gone", "a"])).toEqual(["b", "a"])
})

it("inserts new default ids after their nearest preceding default sibling", () => {
// b is new; its default predecessor a is present, so b lands right after a
expect(mergeOrder(["a", "b", "c"], ["c", "a"])).toEqual(["c", "a", "b"])
})

it("inserts a new head-of-defaults id at the front", () => {
expect(mergeOrder(["new", "a", "b"], ["b", "a"])).toEqual(["new", "b", "a"])
})
})

describe("applySidebarPrefs", () => {
const items = [
{ id: "overview", title: "Overview" },
{ id: "chat", title: "Chat" },
]

it("flags hidden items but keeps them in order", () => {
const rows = applySidebarPrefs("main", items, prefs({ hidden: ["chat"] }))
expect(rows.map((r) => [r.item.id, r.hidden])).toEqual([
["overview", false],
["chat", true],
])
})

it("never hides locked items even when stored as hidden", () => {
const rows = applySidebarPrefs("main", items, prefs({ hidden: ["overview"] }))
expect(rows[0]).toMatchObject({ item: { id: "overview" }, hidden: false })
})

it("pins locked items back to their default index", () => {
const rows = applySidebarPrefs("main", items, prefs({ order: { main: ["chat", "overview"] } }))
expect(rows.map((r) => r.item.id)).toEqual(["overview", "chat"])
})

it("keeps gated-out items absent even if present in stored order", () => {
const gated = [{ id: "traces", title: "Traces" }]
const rows = applySidebarPrefs("signals", gated, prefs({ order: { signals: ["metrics", "traces"] } }))
expect(rows.map((r) => r.item.id)).toEqual(["traces"])
})

it("orders and hides infra-gated signals items by stable id", () => {
const gatedItems = visibleSignalsNavItems({ infraEnabled: false })
const infra = gatedItems.find((item) => item.id === "infrastructure")
expect(infra?.href).toBe("/infra/cloudflare")

const rows = applySidebarPrefs(
"signals",
gatedItems,
prefs({
order: { signals: ["infrastructure", "logs", "traces", "metrics", "replays"] },
hidden: ["replays"],
}),
)
expect(rows.map((r) => r.item.id)).toEqual(["infrastructure", "logs", "traces", "metrics", "replays"])
expect(rows.find((r) => r.item.id === "replays")?.hidden).toBe(true)
expect(rows.find((r) => r.item.id === "infrastructure")?.item.href).toBe("/infra/cloudflare")
})

it("locks overview", () => {
expect(LOCKED_NAV_IDS.has("overview")).toBe(true)
})
})
94 changes: 94 additions & 0 deletions apps/web/src/atoms/sidebar-preferences-atoms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Atom } from "@/lib/effect-atom"
import { Schema } from "effect"
import { localStorageRuntime } from "@/lib/services/common/storage-runtime"

export interface SidebarPrefs {
/** Group id -> ordered item ids. Only groups the user has reordered. */
order: Record<string, readonly string[]>
/** Hidden item ids across all groups. */
hidden: readonly string[]
}

const SidebarPrefsSchema = Schema.Struct({
order: Schema.Record(Schema.String, Schema.Array(Schema.String)),
hidden: Schema.Array(Schema.String),
}) as Schema.Codec<SidebarPrefs>

export const defaultSidebarPrefs: SidebarPrefs = { order: {}, hidden: [] }

// Decode failure falls back to defaultValue; bump the key on incompatible shape changes.
export const sidebarPrefsAtom = Atom.kvs({
runtime: localStorageRuntime,
key: "maple.sidebar.prefs",
schema: SidebarPrefsSchema,
defaultValue: () => defaultSidebarPrefs,
})

/** Items that can never be hidden or displaced from their default position. */
export const LOCKED_NAV_IDS: ReadonlySet<string> = new Set(["overview"])

/**
* Merge a stored order with the current defaults:
* - stored ids not in defaults are dropped (removed/gated-off items)
* - default ids missing from stored are inserted at their default position,
* immediately after the nearest preceding default sibling already present
* (or at the front if none) — so items shipped in future releases show up
* where they belong instead of vanishing or piling up at the end.
*/
export function mergeOrder(defaults: readonly string[], stored: readonly string[]): string[] {
const defaultSet = new Set(defaults)
const result = stored.filter((id) => defaultSet.has(id))
const present = new Set(result)
for (let i = 0; i < defaults.length; i++) {
const id = defaults[i]
if (present.has(id)) continue
let insertAt = 0
for (let j = i - 1; j >= 0; j--) {
const idx = result.indexOf(defaults[j])
if (idx >= 0) {
insertAt = idx + 1
break
}
}
result.splice(insertAt, 0, id)
present.add(id)
}
return result
}

export interface SidebarNavRow<T> {
item: T
hidden: boolean
}

/**
* Apply user prefs to one already-feature-gated group. Returns rows in display
* order, each flagged hidden (edit mode shows hidden rows dimmed; normal mode
* filters them out). Locked ids are never hidden and are pinned back to their
* default index, so drops that displace them self-heal on the next render.
*/
export function applySidebarPrefs<T extends { id: string }>(
groupId: string,
items: readonly T[],
prefs: SidebarPrefs,
locked: ReadonlySet<string> = LOCKED_NAV_IDS,
): Array<SidebarNavRow<T>> {
const defaults = items.map((item) => item.id)
const orderIds = mergeOrder(defaults, prefs.order[groupId] ?? [])
for (const id of defaults) {
if (!locked.has(id)) continue
const current = orderIds.indexOf(id)
const target = Math.min(defaults.indexOf(id), orderIds.length - 1)
if (current !== target) {
orderIds.splice(current, 1)
orderIds.splice(target, 0, id)
}
}
const byId = new Map(items.map((item) => [item.id, item]))
const hiddenSet = new Set(prefs.hidden)
return orderIds.flatMap((id) => {
const item = byId.get(id)
if (!item) return []
return [{ item, hidden: hiddenSet.has(id) && !locked.has(id) }]
})
}
Loading
Loading