From 4bff97090ba988c74bdfde588c69df1147cb62e5 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 16:19:55 -0700 Subject: [PATCH] feat(studio): add usePersistedPinnedGroups hook Reads/writes the per-element-kind pinned-groups map added to studioUiPreferences in the prior task, read-modify-writing the whole map since writeStudioUiPreferences only shallow-merges top-level keys. --- .../hooks/usePersistedPinnedGroups.test.ts | 68 +++++++++++++++++++ .../src/hooks/usePersistedPinnedGroups.ts | 26 +++++++ 2 files changed, 94 insertions(+) create mode 100644 packages/studio/src/hooks/usePersistedPinnedGroups.test.ts create mode 100644 packages/studio/src/hooks/usePersistedPinnedGroups.ts diff --git a/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts b/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts new file mode 100644 index 000000000..f233bae7b --- /dev/null +++ b/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { usePersistedPinnedGroups } from "./usePersistedPinnedGroups"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; + window.localStorage.clear(); +}); + +function Harness({ + elementKind, + onReady, +}: { + elementKind: string; + onReady: (api: ReturnType) => void; +}) { + const api = usePersistedPinnedGroups(elementKind); + onReady(api); + return null; +} + +function mount(elementKind: string) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + let api!: ReturnType; + act(() => { + root.render(React.createElement(Harness, { elementKind, onReady: (a) => (api = a) })); + }); + return { + host, + root, + get api() { + return api; + }, + }; +} + +describe("usePersistedPinnedGroups", () => { + it("starts empty, toggling a pin adds it, toggling again removes it", () => { + const m = mount("text"); + expect(m.api.pinnedGroupIds).toEqual([]); + act(() => m.api.togglePin("motion")); + expect(m.api.pinnedGroupIds).toEqual(["motion"]); + act(() => m.api.togglePin("motion")); + expect(m.api.pinnedGroupIds).toEqual([]); + act(() => m.root.unmount()); + }); + + it("persists across remounts, scoped per element kind", () => { + const first = mount("text"); + act(() => first.api.togglePin("motion")); + act(() => first.root.unmount()); + + const secondSameKind = mount("text"); + expect(secondSameKind.api.pinnedGroupIds).toEqual(["motion"]); + act(() => secondSameKind.root.unmount()); + + const thirdOtherKind = mount("media"); + expect(thirdOtherKind.api.pinnedGroupIds).toEqual([]); + act(() => thirdOtherKind.root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/usePersistedPinnedGroups.ts b/packages/studio/src/hooks/usePersistedPinnedGroups.ts new file mode 100644 index 000000000..57e364f2f --- /dev/null +++ b/packages/studio/src/hooks/usePersistedPinnedGroups.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from "react"; +import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences"; + +export function usePersistedPinnedGroups(elementKind: string) { + const [pinnedGroupIds, setPinnedGroupIds] = useState( + () => readStudioUiPreferences().pinnedGroupsByElementType?.[elementKind] ?? [], + ); + + const togglePin = useCallback( + (groupId: string) => { + setPinnedGroupIds((current) => { + const next = current.includes(groupId) + ? current.filter((id) => id !== groupId) + : [...current, groupId]; + const existing = readStudioUiPreferences().pinnedGroupsByElementType ?? {}; + writeStudioUiPreferences({ + pinnedGroupsByElementType: { ...existing, [elementKind]: next }, + }); + return next; + }); + }, + [elementKind], + ); + + return { pinnedGroupIds, togglePin }; +}