From 2b465c16b5e69c0dacc6d5ab88fab15cd21bfdc6 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Wed, 17 Dec 2025 10:24:34 -0800 Subject: [PATCH] refactor: move favorite libs to indexdb library --- .../Dialogs/ComponentDuplicateDialog.test.tsx | 2 - .../shared/FavoriteComponentToggle.tsx | 22 ++- .../FlowSidebar/sections/GraphComponents.tsx | 22 ++- .../ComponentLibraryProvider.test.tsx | 83 ---------- .../ComponentLibraryProvider.tsx | 142 +++--------------- .../componentLibrary.ts | 12 +- .../libraries/favoriteLibrary.ts | 69 +++++++++ .../libraries/storage.ts | 2 +- src/utils/componentStore.ts | 19 +-- src/utils/localforage.ts | 10 ++ vitest-setup.js | 1 + 11 files changed, 127 insertions(+), 257 deletions(-) create mode 100644 src/providers/ComponentLibraryProvider/libraries/favoriteLibrary.ts diff --git a/src/components/shared/Dialogs/ComponentDuplicateDialog.test.tsx b/src/components/shared/Dialogs/ComponentDuplicateDialog.test.tsx index 2e23912a8..73d19ad16 100644 --- a/src/components/shared/Dialogs/ComponentDuplicateDialog.test.tsx +++ b/src/components/shared/Dialogs/ComponentDuplicateDialog.test.tsx @@ -40,7 +40,6 @@ const createMockComponentLibraryContext = ( componentLibrary: undefined, userComponentsFolder, usedComponentsFolder: { name: "Used", components: [] }, - favoritesFolder: { name: "Favorites", components: [] }, isLoading: false, error: null, existingComponentLibraries: undefined, @@ -48,7 +47,6 @@ const createMockComponentLibraryContext = ( searchComponentLibrary: vi.fn(), addToComponentLibrary: vi.fn(), removeFromComponentLibrary: vi.fn(), - setComponentFavorite: vi.fn(), checkIfUserComponent: vi.fn().mockReturnValue(false), getComponentLibrary: vi.fn(), }; diff --git a/src/components/shared/FavoriteComponentToggle.tsx b/src/components/shared/FavoriteComponentToggle.tsx index 77f8863f0..2867f5fc9 100644 --- a/src/components/shared/FavoriteComponentToggle.tsx +++ b/src/components/shared/FavoriteComponentToggle.tsx @@ -14,10 +14,7 @@ import { Spinner } from "@/components/ui/spinner"; import { useGuaranteedHydrateComponentReference } from "@/hooks/useHydrateComponentReference"; import { cn } from "@/lib/utils"; import { useComponentLibrary } from "@/providers/ComponentLibraryProvider"; -import { - flattenFolders, - isFavoriteComponent, -} from "@/providers/ComponentLibraryProvider/componentLibrary"; +import { flattenFolders } from "@/providers/ComponentLibraryProvider/componentLibrary"; import { hydrateComponentReference } from "@/services/componentService"; import { type ComponentReference } from "@/utils/componentSpec"; import { MINUTES } from "@/utils/constants"; @@ -108,17 +105,26 @@ const FavoriteToggleButton = withSuspenseWrapper( ({ component }: { component: ComponentReference }) => { const queryClient = useQueryClient(); - const { setComponentFavorite } = useComponentLibrary(); + const { getComponentLibrary } = useComponentLibrary(); + const favoriteComponentsLibrary = getComponentLibrary( + "favorite_components", + ); const hydratedComponent = useGuaranteedHydrateComponentReference(component); const { data: isFavorited } = useSuspenseQuery({ queryKey: favoriteComponentKey(hydratedComponent), - queryFn: async () => isFavoriteComponent(hydratedComponent), + queryFn: async () => + favoriteComponentsLibrary.hasComponent(hydratedComponent), }); const { mutate: setFavorite } = useMutation({ - mutationFn: async () => - setComponentFavorite(hydratedComponent, !isFavorited), + mutationFn: async () => { + if (isFavorited) { + await favoriteComponentsLibrary.removeComponent(hydratedComponent); + } else { + await favoriteComponentsLibrary.addComponent(hydratedComponent); + } + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: favoriteComponentKey(hydratedComponent), diff --git a/src/components/shared/ReactFlow/FlowSidebar/sections/GraphComponents.tsx b/src/components/shared/ReactFlow/FlowSidebar/sections/GraphComponents.tsx index 2e79d65dc..12a70727c 100644 --- a/src/components/shared/ReactFlow/FlowSidebar/sections/GraphComponents.tsx +++ b/src/components/shared/ReactFlow/FlowSidebar/sections/GraphComponents.tsx @@ -39,7 +39,6 @@ const GraphComponents = ({ isOpen }: { isOpen: boolean }) => { const remoteComponentLibrarySearchEnabled = useBetaFlagValue( "remote-component-library-search", ); - const { updateSearchFilter, currentSearchFilter } = useForcedSearchContext(); const handleSearchChange = (e: ChangeEvent) => { @@ -121,7 +120,6 @@ function ComponentLibrarySection() { const remoteComponentLibrarySearchEnabled = useBetaFlagValue( "remote-component-library-search", ); - const githubComponentLibraryEnabled = useBetaFlagValue( "github-component-library", ); @@ -129,12 +127,13 @@ function ComponentLibrarySection() { const { getComponentLibrary, existingComponentLibraries } = useComponentLibrary(); + const favoriteComponentsLibrary = getComponentLibrary("favorite_components"); + const { updateSearchFilter } = useForcedSearchContext(); const { componentLibrary, usedComponentsFolder, userComponentsFolder, - favoritesFolder, isLoading, error, searchResult, @@ -165,9 +164,6 @@ function ComponentLibrarySection() { usedComponentsFolder?.components && usedComponentsFolder.components.length > 0; - const hasFavouriteComponents = - favoritesFolder?.components && favoritesFolder.components.length > 0; - const hasUserComponents = userComponentsFolder?.components && userComponentsFolder.components.length > 0; @@ -184,13 +180,13 @@ function ComponentLibrarySection() { icon="LayoutGrid" /> )} - {hasFavouriteComponents && ( - - )} + + + {hasUserComponents && ( { @@ -537,82 +532,4 @@ describe("ComponentLibraryProvider - Component Management", () => { expect(isUserComponent).toBe(false); }); }); - - describe("Component Favoriting", () => { - it("should set component as favorite for user components", async () => { - const userComponent: ComponentReference = { - name: "user-component", - digest: "user-digest", - spec: mockComponentSpec, - text: "user yaml content", - }; - - mockUpdateComponentRefInList.mockResolvedValue({ - componentRef: userComponent, - name: "user-component", - data: new ArrayBuffer(0), - creationTime: new Date(), - modificationTime: new Date(), - } as any); - - const { result } = renderHook(() => useComponentLibrary(), { - wrapper: createWrapper, - }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await act(async () => { - await result.current.setComponentFavorite(userComponent, true); - }); - - expect(mockUpdateComponentRefInList).toHaveBeenCalledWith( - USER_COMPONENTS_LIST_NAME, - expect.objectContaining({ favorited: true }), - "user-component", - ); - }); - - it("should set component as favorite for standard components", async () => { - const standardComponent: ComponentReference = { - name: "standard-component", - digest: "standard-digest", - url: "https://example.com/standard.yaml", - spec: mockComponentSpec, - }; - - const mockStoredComponent = { - id: "stored-1", - url: "https://example.com/standard.yaml", - data: "stored yaml", - createdAt: Date.now(), - updatedAt: Date.now(), - favorited: false, - }; - - mockGetComponentByUrl.mockResolvedValue(mockStoredComponent); - mockSaveComponent.mockResolvedValue(mockStoredComponent as any); - - const { result } = renderHook(() => useComponentLibrary(), { - wrapper: createWrapper, - }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await act(async () => { - await result.current.setComponentFavorite(standardComponent, true); - }); - - expect(mockGetComponentByUrl).toHaveBeenCalledWith( - "https://example.com/standard.yaml", - ); - expect(mockSaveComponent).toHaveBeenCalledWith({ - ...mockStoredComponent, - favorited: true, - }); - }); - }); }); diff --git a/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx b/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx index d17972d93..7f60876a1 100644 --- a/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx +++ b/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx @@ -28,19 +28,14 @@ import type { HydratedComponentReference, } from "@/utils/componentSpec"; import { - type ComponentReferenceWithSpec, deleteComponentFileFromList, importComponent, - updateComponentInListByText, - updateComponentRefInList, } from "@/utils/componentStore"; -import { MINUTES, USER_COMPONENTS_LIST_NAME } from "@/utils/constants"; +import { USER_COMPONENTS_LIST_NAME } from "@/utils/constants"; import { createPromiseFromDomEvent } from "@/utils/dom"; import { getComponentName } from "@/utils/getComponentName"; import { - getComponentByUrl, getUserComponentByName, - saveComponent, type UserComponent, } from "@/utils/localforage"; import { componentMatchesSearch } from "@/utils/searchUtils"; @@ -55,7 +50,6 @@ import { fetchUserComponents, filterToUniqueByDigest, flattenFolders, - isFavoriteComponent, populateComponentRefs, } from "./componentLibrary"; import { useForcedSearchContext } from "./ForcedSearchProvider"; @@ -63,18 +57,21 @@ import { createLibraryObject, registerLibraryFactory, } from "./libraries/factory"; +import { FavoriteLibrary } from "./libraries/favoriteLibrary"; import { PublishedComponentsLibrary } from "./libraries/publishedComponentsLibrary"; import { LibraryDB, type StoredLibrary } from "./libraries/storage"; import type { Library } from "./libraries/types"; import { YamlFileLibrary } from "./libraries/yamlFileLibrary"; -type AvailableComponentLibraries = "published_components" | string; +type AvailableComponentLibraries = + | "published_components" + | "favorite_components" + | string; type ComponentLibraryContextType = { componentLibrary: ComponentLibrary | undefined; userComponentsFolder: ComponentFolder | undefined; usedComponentsFolder: ComponentFolder; - favoritesFolder: ComponentFolder | undefined; isLoading: boolean; error: Error | null; existingComponentLibraries: StoredLibrary[] | undefined; @@ -88,10 +85,7 @@ type ComponentLibraryContextType = { component: HydratedComponentReference, ) => Promise; removeFromComponentLibrary: (component: ComponentReference) => void; - setComponentFavorite: ( - component: ComponentReference, - favorited: boolean, - ) => void; + checkIfUserComponent: (component: ComponentReference) => boolean; getComponentLibrary: (libraryName: AvailableComponentLibraries) => Library; @@ -138,6 +132,7 @@ function useComponentLibraryRegistry() { () => new Map([ ["published_components", new PublishedComponentsLibrary(queryClient)], + ["favorite_components", new FavoriteLibrary()], /** * In future we will have other library types, including "standard_library", "favorite_components", "used_components", etc. */ @@ -153,11 +148,18 @@ function useComponentLibraryRegistry() { ); useEffect(() => { - registeredComponentLibraries?.forEach((library) => { - componentLibraries.set(library.id, createLibraryObject(library)); - }); + const customLibraries = + registeredComponentLibraries?.filter( + (library) => library.type !== "indexdb", + ) ?? []; + + customLibraries + .filter((library) => !componentLibraries.has(library.id)) + .forEach((library) => { + componentLibraries.set(library.id, createLibraryObject(library)); + }); - setExistingComponentLibraries(registeredComponentLibraries ?? []); + setExistingComponentLibraries(customLibraries); }, [registeredComponentLibraries, componentLibraries]); const getComponentLibraryObject = useCallback( @@ -224,57 +226,14 @@ export const ComponentLibraryProvider = ({ [graphSpec], ); - // Fetch "Starred" components - const { data: favoritesFolderData, refetch: refetchFavorites } = useQuery({ - queryKey: ["favorites"], - queryFn: async () => { - const favoritesFolder: ComponentFolder = { - name: "Favorite Components", - components: [], - folders: [], - isUserFolder: false, - }; - - if (!componentLibrary || !componentLibrary.folders) { - return favoritesFolder; - } - - const uniqueLibraryComponents = filterToUniqueByDigest( - flattenFolders(componentLibrary), - ); - - for (const component of uniqueLibraryComponents) { - if (await isFavoriteComponent(component)) { - favoritesFolder.components?.push(component); - } - } - - return favoritesFolder; - }, - enabled: Boolean(componentLibrary), - staleTime: 10 * MINUTES, - }); - - const favoritesFolder = useMemo( - () => - favoritesFolderData ?? { - name: "Favorite Components", - components: [], - folders: [], - isUserFolder: false, - }, - [favoritesFolderData], - ); - // Methods const refreshComponentLibrary = useCallback(async () => { const { data: updatedLibrary } = await refetchLibrary(); if (updatedLibrary) { setComponentLibrary(updatedLibrary); - await refetchFavorites(); } - }, [refetchLibrary, refetchFavorites]); + }, [refetchLibrary]); const refreshUserComponents = useCallback(async () => { const { data: updatedUserComponents } = await refetchUserComponents(); @@ -286,63 +245,6 @@ export const ComponentLibraryProvider = ({ } }, [refetchUserComponents]); - const setComponentFavorite = useCallback( - async (component: ComponentReference, favorited: boolean) => { - // Update via filename (User Components) - if (!component.url && component.name) { - component.favorited = favorited; - - if (component.spec) { - await updateComponentRefInList( - USER_COMPONENTS_LIST_NAME, - component as ComponentReferenceWithSpec, - component.name, - ).then(async () => { - await refreshUserComponents(); - }); - } else if (component.text) { - await updateComponentInListByText( - USER_COMPONENTS_LIST_NAME, - component.text, - component.name, - { favorited }, - ).then(async () => { - await refreshUserComponents(); - }); - } else { - console.warn( - `Component "${ - component.name - }" does not have spec or text, cannot favorite.`, - ); - } - - return; - } - - if (!component.url) { - console.warn( - `Component "${component.name}" does not have a url, cannot favorite.`, - ); - return; - } - - // Update via url (Standard Components) - const storedComponent = await getComponentByUrl(component.url); - - if (storedComponent) { - await saveComponent({ - ...storedComponent, - favorited, - }).then(async () => { - await refreshComponentLibrary(); - await refreshUserComponents(); - }); - } - }, - [refreshComponentLibrary, refreshUserComponents], - ); - const checkIfUserComponent = useCallback( (component: ComponentReference) => { if (!userComponentsFolder) return false; @@ -582,7 +484,6 @@ export const ComponentLibraryProvider = ({ componentLibrary, userComponentsFolder, usedComponentsFolder, - favoritesFolder, isLoading, error, searchResult, @@ -591,14 +492,12 @@ export const ComponentLibraryProvider = ({ getComponentLibrary, addToComponentLibrary, removeFromComponentLibrary, - setComponentFavorite, checkIfUserComponent, }), [ componentLibrary, userComponentsFolder, usedComponentsFolder, - favoritesFolder, isLoading, error, searchResult, @@ -607,7 +506,6 @@ export const ComponentLibraryProvider = ({ getComponentLibrary, addToComponentLibrary, removeFromComponentLibrary, - setComponentFavorite, checkIfUserComponent, ], ); diff --git a/src/providers/ComponentLibraryProvider/componentLibrary.ts b/src/providers/ComponentLibraryProvider/componentLibrary.ts index 5f4e0057a..3e1e377d4 100644 --- a/src/providers/ComponentLibraryProvider/componentLibrary.ts +++ b/src/providers/ComponentLibraryProvider/componentLibrary.ts @@ -13,7 +13,7 @@ import { getAllComponentFilesFromList, } from "@/utils/componentStore"; import { USER_COMPONENTS_LIST_NAME } from "@/utils/constants"; -import { getComponentById, getComponentByUrl } from "@/utils/localforage"; +import { getComponentByUrl } from "@/utils/localforage"; import { componentSpecToYaml } from "@/utils/yaml"; export const fetchUserComponents = async (): Promise => { @@ -80,16 +80,6 @@ export const fetchUsedComponents = (graphSpec: GraphSpec): ComponentFolder => { }; }; -export async function isFavoriteComponent(component: ComponentReference) { - if (!component.digest) return false; - - const storedComponent = await getComponentById( - `component-${component.digest}`, - ); - - return storedComponent?.favorited ?? false; -} - export async function populateComponentRefs< T extends ComponentLibrary | ComponentFolder, >(libraryOrFolder: T): Promise { diff --git a/src/providers/ComponentLibraryProvider/libraries/favoriteLibrary.ts b/src/providers/ComponentLibraryProvider/libraries/favoriteLibrary.ts new file mode 100644 index 000000000..272257ce7 --- /dev/null +++ b/src/providers/ComponentLibraryProvider/libraries/favoriteLibrary.ts @@ -0,0 +1,69 @@ +import { hydrateComponentReference } from "@/services/componentService"; +import { iterateOverAllComponents } from "@/utils/localforage"; + +import { BrowserPersistedLibrary } from "./browserPersistedLibrary"; +import { LibraryDB, type StoredLibraryItem } from "./storage"; + +const FAVORITE_COMPONENTS_LIBRARY_ID = "favorite_components"; + +export class FavoriteLibrary extends BrowserPersistedLibrary { + constructor() { + super(FAVORITE_COMPONENTS_LIBRARY_ID, () => migrateLegacyFavoriteFolder()); + } +} + +/** + * Migrate the favorite components to the new database. + * This action supposed to happen only once. + * + * @returns + */ +async function migrateLegacyFavoriteFolder() { + /** + * Migrate the favorite components to the new database + */ + const favoriteComponents: StoredLibraryItem[] = []; + await iterateOverAllComponents(async (component) => { + if (component.favorited) { + console.log( + `Migrating component ${component.id} to favorite library. Favorited: ${component.favorited}`, + component, + ); + + const hydratedComponent = await hydrateComponentReference({ + url: component.url, + text: component.data, + }); + + if (!hydratedComponent) { + console.error( + `Failed to migrate component "${component.id}" to favorite library`, + component, + ); + return; + } + + favoriteComponents.push({ + digest: hydratedComponent.digest, + name: hydratedComponent.name, + url: hydratedComponent.url, + } as StoredLibraryItem); + } + }); + + await LibraryDB.component_libraries.put({ + id: FAVORITE_COMPONENTS_LIBRARY_ID, + name: "Favorite Components", + icon: "Star", + type: "indexdb", + knownDigests: favoriteComponents.map((component) => component.digest), + configuration: { + migrated_at: new Date().toISOString(), + }, + components: favoriteComponents, + }); + + return await LibraryDB.component_libraries.get( + FAVORITE_COMPONENTS_LIBRARY_ID, + ); +} diff --git a/src/providers/ComponentLibraryProvider/libraries/storage.ts b/src/providers/ComponentLibraryProvider/libraries/storage.ts index 78996c0ba..8aba1389d 100644 --- a/src/providers/ComponentLibraryProvider/libraries/storage.ts +++ b/src/providers/ComponentLibraryProvider/libraries/storage.ts @@ -4,7 +4,7 @@ import { icons } from "lucide-react"; const DB_NAME = "oasis-app"; const DEXIE_EPOCH = 0; -interface StoredLibraryItem { +export interface StoredLibraryItem { digest: string; name: string; url?: string; diff --git a/src/utils/componentStore.ts b/src/utils/componentStore.ts index 5f01b5d85..41a031ed9 100644 --- a/src/utils/componentStore.ts +++ b/src/utils/componentStore.ts @@ -274,7 +274,8 @@ interface FileEntry { } interface ComponentFileEntryV3 - extends FileEntry, ComponentReferenceWithSpecPlusData {} + extends FileEntry, + ComponentReferenceWithSpecPlusData {} export type ComponentFileEntry = ComponentFileEntryV3; @@ -478,22 +479,6 @@ const addComponentToListByTextWithDuplicateCheck = async ( return fileEntry; }; -export const updateComponentInListByText = async ( - listName: string, - componentText: string | ArrayBuffer, - fileName: string, - additionalData?: { - [K: string]: any; - }, -) => { - const componentRef = await storeComponentText(componentText); - if (additionalData) { - // Merge additional data into the component reference - Object.assign(componentRef, additionalData); - } - return updateComponentRefInList(listName, componentRef, fileName); -}; - export const writeComponentToFileListFromText = async ( listName: string, fileName: string, diff --git a/src/utils/localforage.ts b/src/utils/localforage.ts index 9ed7b8438..29e5689a9 100644 --- a/src/utils/localforage.ts +++ b/src/utils/localforage.ts @@ -76,6 +76,16 @@ export async function getComponentById(id: string): Promise { return componentStore.getItem(id); } +export async function iterateOverAllComponents( + visitorFn: (component: Component) => Promise, +): Promise { + const promises: Promise[] = []; + await componentStore.iterate((component) => { + promises.push(visitorFn(component)); + }); + await Promise.all(promises); +} + // Function to get a component by URL export async function getComponentByUrl( url: string, diff --git a/vitest-setup.js b/vitest-setup.js index f149f27ae..2c29eea00 100644 --- a/vitest-setup.js +++ b/vitest-setup.js @@ -1 +1,2 @@ import "@testing-library/jest-dom/vitest"; +import "fake-indexeddb/auto";