diff --git a/src/components/shared/Dialogs/ComponentDetailsDialog.tsx b/src/components/shared/Dialogs/ComponentDetailsDialog.tsx index e6879bccb..72290cd8a 100644 --- a/src/components/shared/Dialogs/ComponentDetailsDialog.tsx +++ b/src/components/shared/Dialogs/ComponentDetailsDialog.tsx @@ -1,3 +1,4 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; import { Code, InfoIcon, ListFilter } from "lucide-react"; import { type ReactNode, useCallback, useMemo, useState } from "react"; @@ -13,7 +14,8 @@ import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useHydrateComponentReference } from "@/hooks/useHydrateComponentReference"; +import { useGuaranteedHydrateComponentReference } from "@/hooks/useHydrateComponentReference"; +import { useComponentLibrary } from "@/providers/ComponentLibraryProvider/ComponentLibraryProvider"; import type { ComponentReference } from "@/utils/componentSpec"; import InfoIconButton from "../Buttons/InfoIconButton"; @@ -74,7 +76,14 @@ const ComponentDetailsDialogContent = withSuspenseWrapper( "remote-component-library-search", ); - const componentRef = useHydrateComponentReference(component); + const componentRef = useGuaranteedHydrateComponentReference(component); + const { getComponentLibrary } = useComponentLibrary(); + const userComponentsLibrary = getComponentLibrary("user_components"); + + const { data: isUserComponent } = useSuspenseQuery({ + queryKey: ["is-user-component", componentRef.digest], + queryFn: () => userComponentsLibrary.hasComponent(componentRef), + }); if (!componentRef) { return ( @@ -87,7 +96,7 @@ const ComponentDetailsDialogContent = withSuspenseWrapper( const { url, spec: componentSpec, digest: componentDigest } = componentRef; const hasPublishSection = - remoteComponentLibrarySearchEnabled && component.owned; + remoteComponentLibrarySearchEnabled && isUserComponent; return ( <> diff --git a/src/components/shared/Dialogs/ComponentDuplicateDialog.test.tsx b/src/components/shared/Dialogs/ComponentDuplicateDialog.test.tsx index 73d19ad16..6cb3d0327 100644 --- a/src/components/shared/Dialogs/ComponentDuplicateDialog.test.tsx +++ b/src/components/shared/Dialogs/ComponentDuplicateDialog.test.tsx @@ -47,7 +47,6 @@ const createMockComponentLibraryContext = ( searchComponentLibrary: vi.fn(), addToComponentLibrary: vi.fn(), removeFromComponentLibrary: 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 d072a28ac..7a028d62d 100644 --- a/src/components/shared/FavoriteComponentToggle.tsx +++ b/src/components/shared/FavoriteComponentToggle.tsx @@ -142,27 +142,29 @@ const FavoriteToggleButton = withSuspenseWrapper( ); const useComponentFlags = (component: ComponentReference) => { - const { checkIfUserComponent, getComponentLibrary } = useComponentLibrary(); + const { getComponentLibrary } = useComponentLibrary(); const componentLibrary = getComponentLibrary("standard_components"); + const userComponentsLibrary = getComponentLibrary("user_components"); - const isUserComponent = useMemo( - () => checkIfUserComponent(component), - [component, checkIfUserComponent], - ); - - const { data: isInLibrary } = useSuspenseQuery({ + const { data } = useSuspenseQuery({ queryKey: ["component", "flags", component.digest], queryFn: async () => { - if (!componentLibrary) return false; + if (!componentLibrary) + return { isInLibrary: false, isUserComponent: false }; - if (isUserComponent) return true; + const isUserComponent = + await userComponentsLibrary.hasComponent(component); - return componentLibrary.hasComponent(component); + return { + isInLibrary: + isUserComponent || (await componentLibrary.hasComponent(component)), + isUserComponent, + }; }, staleTime: 10 * MINUTES, }); - return { isInLibrary, isUserComponent }; + return data; }; const ComponentFavoriteToggleInternal = ({ diff --git a/src/components/shared/ReactFlow/FlowSidebar/sections/GraphComponents.tsx b/src/components/shared/ReactFlow/FlowSidebar/sections/GraphComponents.tsx index 75b3d2671..c942201f0 100644 --- a/src/components/shared/ReactFlow/FlowSidebar/sections/GraphComponents.tsx +++ b/src/components/shared/ReactFlow/FlowSidebar/sections/GraphComponents.tsx @@ -127,15 +127,11 @@ function ComponentLibrarySection() { useComponentLibrary(); const favoriteComponentsLibrary = getComponentLibrary("favorite_components"); + const userComponentsLibrary = getComponentLibrary("user_components"); const { updateSearchFilter } = useForcedSearchContext(); - const { - usedComponentsFolder, - userComponentsFolder, - isLoading, - error, - searchResult, - } = useComponentLibrary(); + const { usedComponentsFolder, isLoading, error, searchResult } = + useComponentLibrary(); const standardComponentsLibrary = getComponentLibrary("standard_components"); @@ -163,10 +159,6 @@ function ComponentLibrarySection() { usedComponentsFolder?.components && usedComponentsFolder.components.length > 0; - const hasUserComponents = - userComponentsFolder?.components && - userComponentsFolder.components.length > 0; - return ( {remoteComponentLibrarySearchEnabled && } @@ -186,13 +178,11 @@ function ComponentLibrarySection() { icon="Star" /> - {hasUserComponents && ( - - )} + { @@ -79,36 +66,6 @@ describe("ComponentLibraryProvider - Component Management", () => { }, }; - const mockComponentLibrary: ComponentLibrary = { - folders: [ - { - name: "Test Folder", - components: [ - { - name: "test-component", - digest: "test-digest-1", - url: "https://example.com/component1.yaml", - spec: mockComponentSpec, - }, - ], - folders: [], - }, - ], - }; - - const mockUserComponentsFolder: ComponentFolder = { - name: "User Components", - components: [ - { - name: "user-component", - digest: "user-digest-1", - spec: mockComponentSpec, - text: "test yaml content", - }, - ], - folders: [], - }; - const createWrapper = ({ children }: { children: ReactNode }) => { const queryClient = new QueryClient({ defaultOptions: { @@ -131,13 +88,12 @@ describe("ComponentLibraryProvider - Component Management", () => { componentDuplicateDialogProps.handleImportComponent = undefined; // Setup default mock implementations - mockFetchUserComponents.mockResolvedValue(mockUserComponentsFolder); mockFetchUsedComponents.mockReturnValue({ name: "Used Components", components: [], folders: [], }); - mockPopulateComponentRefs.mockImplementation((lib) => Promise.resolve(lib)); + mockFlattenFolders.mockImplementation((folder) => { if ("folders" in folder) { return folder.folders?.flatMap((f) => f.components || []) || []; @@ -207,16 +163,11 @@ describe("ComponentLibraryProvider - Component Management", () => { // Mock that there's an existing component with the same name mockFlattenFolders.mockReturnValue([existingComponent]); - mockGetUserComponentByName.mockResolvedValue(mockUserComponent); const { result } = renderHook(() => useComponentLibrary(), { wrapper: createWrapper, }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - await act(async () => { void result.current.addToComponentLibrary(newComponent); }); @@ -481,51 +432,4 @@ describe("ComponentLibraryProvider - Component Management", () => { consoleSpy.mockRestore(); }); }); - - describe("Component Checks", () => { - it("should correctly identify user components", async () => { - const userComponent: ComponentReference = { - name: "user-component", - digest: "user-digest-1", - spec: mockComponentSpec, - }; - - mockFlattenFolders.mockReturnValue([userComponent]); - mockFilterToUniqueByDigest.mockReturnValue([userComponent]); - - const { result } = renderHook(() => useComponentLibrary(), { - wrapper: createWrapper, - }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - const isUserComponent = - result.current.checkIfUserComponent(userComponent); - expect(isUserComponent).toBe(true); - }); - - it("should correctly identify non-user components", async () => { - const standardComponent: ComponentReference = { - name: "standard-component", - digest: "standard-digest", - spec: mockComponentSpec, - }; - - mockFlattenFolders.mockReturnValue([]); // No user components - - const { result } = renderHook(() => useComponentLibrary(), { - wrapper: createWrapper, - }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - const isUserComponent = - result.current.checkIfUserComponent(standardComponent); - expect(isUserComponent).toBe(false); - }); - }); }); diff --git a/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx b/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx index da76399e9..79f67dae4 100644 --- a/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx +++ b/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx @@ -1,4 +1,4 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { useLiveQuery } from "dexie-react-hooks"; import { type ReactNode, @@ -23,17 +23,8 @@ import type { ComponentReference, HydratedComponentReference, } from "@/utils/componentSpec"; -import { - deleteComponentFileFromList, - importComponent, -} from "@/utils/componentStore"; -import { USER_COMPONENTS_LIST_NAME } from "@/utils/constants"; import { createPromiseFromDomEvent } from "@/utils/dom"; -import { getComponentName } from "@/utils/getComponentName"; -import { - getUserComponentByName, - type UserComponent, -} from "@/utils/localforage"; +import { type UserComponent } from "@/utils/localforage"; import { componentMatchesSearch } from "@/utils/searchUtils"; import { @@ -43,10 +34,8 @@ import { import { useComponentSpec } from "../ComponentSpecProvider"; import { fetchUsedComponents, - fetchUserComponents, filterToUniqueByDigest, flattenFolders, - populateComponentRefs, } from "./componentLibrary"; import { useForcedSearchContext } from "./ForcedSearchProvider"; import { @@ -57,6 +46,7 @@ import { FavoriteLibrary } from "./libraries/favoriteLibrary"; import { PublishedComponentsLibrary } from "./libraries/publishedComponentsLibrary"; import { LibraryDB, type StoredLibrary } from "./libraries/storage"; import type { Library } from "./libraries/types"; +import { UserComponentsLibrary } from "./libraries/userComponentLibrary"; import { YamlFileLibrary } from "./libraries/yamlFileLibrary"; type AvailableComponentLibraries = @@ -65,10 +55,8 @@ type AvailableComponentLibraries = | string; type ComponentLibraryContextType = { - userComponentsFolder: ComponentFolder | undefined; usedComponentsFolder: ComponentFolder; - isLoading: boolean; - error: Error | null; + existingComponentLibraries: StoredLibrary[] | undefined; searchResult: SearchResult | null; @@ -76,13 +64,12 @@ type ComponentLibraryContextType = { search: string, filters: string[], ) => SearchResult | null; + addToComponentLibrary: ( component: HydratedComponentReference, ) => Promise; removeFromComponentLibrary: (component: ComponentReference) => void; - checkIfUserComponent: (component: ComponentReference) => boolean; - getComponentLibrary: (libraryName: AvailableComponentLibraries) => Library; }; @@ -128,6 +115,7 @@ function useComponentLibraryRegistry() { new Map([ ["published_components", new PublishedComponentsLibrary(queryClient)], ["favorite_components", new FavoriteLibrary()], + ["user_components", new UserComponentsLibrary()], /** * In future we will have other library types, including "standard_library", "favorite_components", "used_components", etc. */ @@ -186,57 +174,17 @@ export const ComponentLibraryProvider = ({ const { getComponentLibraryObject, existingComponentLibraries } = useComponentLibraryRegistry(); - const [userComponentsFolder, setUserComponentsFolder] = - useState(); - const [existingComponent, setExistingComponent] = useState(null); const [newComponent, setNewComponent] = useState(null); - // Fetch user components - const { - data: rawUserComponentsFolder, - isLoading: isUserComponentsLoading, - error: userComponentsError, - refetch: refetchUserComponents, - } = useQuery({ - queryKey: ["userComponents"], - queryFn: fetchUserComponents, - staleTime: 0, - refetchOnMount: "always", - }); - // Fetch "Used in Pipeline" components const usedComponentsFolder: ComponentFolder = useMemo( () => fetchUsedComponents(graphSpec), [graphSpec], ); - // Methods - const refreshUserComponents = useCallback(async () => { - const { data: updatedUserComponents } = await refetchUserComponents(); - - if (updatedUserComponents) { - populateComponentRefs(updatedUserComponents).then((result) => { - setUserComponentsFolder(result); - }); - } - }, [refetchUserComponents]); - - const checkIfUserComponent = useCallback( - (component: ComponentReference) => { - if (!userComponentsFolder) return false; - - const uniqueUserComponents = filterToUniqueByDigest( - flattenFolders(userComponentsFolder), - ); - - return uniqueUserComponents.some((c) => c.digest === component.digest); - }, - [userComponentsFolder], - ); - /** * Local component library search */ @@ -252,16 +200,9 @@ export const ComponentLibraryProvider = ({ }, }; - // classic search is not supported for now + // todo: add standard components search - if (userComponentsFolder) { - const uniqueComponents = filterToUniqueByDigest( - flattenFolders(userComponentsFolder), - ); - result.components.user = uniqueComponents.filter( - (c) => c.spec && componentMatchesSearch(c.spec, search, filters), - ); - } + // todo: add user components search if (usedComponentsFolder) { const uniqueComponents = filterToUniqueByDigest( @@ -274,14 +215,15 @@ export const ComponentLibraryProvider = ({ return result; }, - [userComponentsFolder, usedComponentsFolder], + [usedComponentsFolder], ); const internalAddComponentToLibrary = useCallback( async (hydratedComponent: HydratedComponentReference) => { - await importComponent(hydratedComponent); + await getComponentLibraryObject("user_components").addComponent( + hydratedComponent, + ); - await refreshUserComponents(); setNewComponent(null); setExistingComponent(null); @@ -293,7 +235,7 @@ export const ComponentLibraryProvider = ({ }), ); }, - [refreshUserComponents, importComponent], + [], ); const handleImportComponent = useCallback( @@ -312,26 +254,35 @@ export const ComponentLibraryProvider = ({ console.error("Error importing component:", error); } }, - [newComponent, refreshUserComponents, importComponent], + [newComponent], ); const addToComponentLibraryWithDuplicateCheck = useCallback( async (component: HydratedComponentReference) => { - const duplicate = userComponentsFolder - ? flattenFolders(userComponentsFolder).find( - (c) => getComponentName(c) === getComponentName(component), - ) - : undefined; + const duplicate = await getComponentLibraryObject( + "user_components", + ).getComponents({ + searchTerm: component.name, + filters: ["name"], + }); - const existingUserComponent = duplicate?.name - ? await getUserComponentByName(duplicate.name) - : undefined; + const existingUserComponent = (duplicate?.components ?? []).find( + (c) => c.digest === component.digest, + ); if ( existingUserComponent && - existingUserComponent.componentRef.digest !== component.digest + existingUserComponent.digest !== component.digest ) { - setExistingComponent(existingUserComponent); + setExistingComponent({ + componentRef: existingUserComponent, + // todo: get name from component + name: existingUserComponent.name ?? "", + // todo: get data from component + data: new ArrayBuffer(0), + creationTime: new Date(), + modificationTime: new Date(), + }); setNewComponent(component); return; } @@ -342,7 +293,7 @@ export const ComponentLibraryProvider = ({ console.error("Error adding component to library:", error); } }, - [userComponentsFolder, refreshUserComponents, importComponent], + [], ); const addToComponentLibrary = useCallback( @@ -376,23 +327,14 @@ export const ComponentLibraryProvider = ({ const removeFromComponentLibrary = useCallback( async (component: ComponentReference) => { try { - if (component.name) { - await deleteComponentFileFromList( - USER_COMPONENTS_LIST_NAME, - component.name, - ).then(async () => { - await refreshUserComponents(); - }); - } else { - console.error( - `Error deleting component: Component ${component.digest} does not have a name.`, - ); - } + await getComponentLibraryObject("user_components").removeComponent( + component, + ); } catch (error) { console.error("Error deleting component:", error); } }, - [refreshUserComponents], + [], ); const handleCloseDuplicationDialog = useCallback(() => { @@ -411,16 +353,6 @@ export const ComponentLibraryProvider = ({ [currentSearchFilter, searchComponentLibrary], ); - useEffect(() => { - if (!rawUserComponentsFolder) { - setUserComponentsFolder(undefined); - return; - } - populateComponentRefs(rawUserComponentsFolder).then((result) => { - setUserComponentsFolder(result); - }); - }, [rawUserComponentsFolder]); - const getComponentLibrary = useCallback( (libraryName: AvailableComponentLibraries) => { return getComponentLibraryObject(libraryName); @@ -428,35 +360,24 @@ export const ComponentLibraryProvider = ({ [], ); - const isLoading = isUserComponentsLoading; - const error = userComponentsError; - const value = useMemo( () => ({ - userComponentsFolder, usedComponentsFolder, - isLoading, - error, searchResult, existingComponentLibraries, searchComponentLibrary, getComponentLibrary, addToComponentLibrary, removeFromComponentLibrary, - checkIfUserComponent, }), [ - userComponentsFolder, usedComponentsFolder, - isLoading, - error, searchResult, existingComponentLibraries, searchComponentLibrary, getComponentLibrary, addToComponentLibrary, removeFromComponentLibrary, - checkIfUserComponent, ], ); diff --git a/src/providers/ComponentLibraryProvider/componentLibrary.test.ts b/src/providers/ComponentLibraryProvider/componentLibrary.test.ts index 1fc5b6149..9ee7e7f52 100644 --- a/src/providers/ComponentLibraryProvider/componentLibrary.test.ts +++ b/src/providers/ComponentLibraryProvider/componentLibrary.test.ts @@ -17,7 +17,6 @@ import * as yamlUtils from "@/utils/yaml"; import { fetchUsedComponents, - fetchUserComponents, filterToUniqueByDigest, flattenFolders, populateComponentRefs, @@ -42,128 +41,6 @@ describe("componentLibrary", () => { vi.restoreAllMocks(); }); - describe("fetchUserComponents", () => { - it("should return user components folder with components from store", async () => { - // Arrange - const mockComponentFiles = new Map([ - [ - "component1", - { - name: "Test Component 1", - creationTime: new Date(), - modificationTime: new Date(), - data: new ArrayBuffer(0), - componentRef: { - name: "test-component-1", - digest: "digest1", - url: "https://example.com/component1.yaml", - spec: {} as ComponentSpec, - text: "test-yaml", - }, - }, - ], - [ - "component2", - { - name: "Test Component 2", - creationTime: new Date(), - modificationTime: new Date(), - data: new ArrayBuffer(0), - componentRef: { - name: "test-component-2", - digest: "digest2", - url: "https://example.com/component2.yaml", - spec: {} as ComponentSpec, - text: "test-yaml", - }, - }, - ], - ]); - - mockComponentStore.getAllComponentFilesFromList.mockResolvedValue( - mockComponentFiles, - ); - - // Act - const result = await fetchUserComponents(); - - // Assert - expect(result).toEqual({ - name: "User Components", - components: [ - { - name: "Test Component 1", - digest: "digest1", - url: "https://example.com/component1.yaml", - spec: {}, - text: "test-yaml", - owned: true, - }, - { - name: "Test Component 2", - digest: "digest2", - url: "https://example.com/component2.yaml", - spec: {}, - text: "test-yaml", - owned: true, - }, - ], - folders: [], - isUserFolder: true, - }); - expect( - mockComponentStore.getAllComponentFilesFromList, - ).toHaveBeenCalledWith("user_components"); - }); - - it("should return empty user components folder when no components exist", async () => { - // Arrange - mockComponentStore.getAllComponentFilesFromList.mockResolvedValue( - new Map(), - ); - - // Act - const result = await fetchUserComponents(); - - // Assert - expect(result).toEqual({ - name: "User Components", - components: [], - folders: [], - isUserFolder: true, - }); - }); - - it("should return empty folder on error", async () => { - // Arrange - mockComponentStore.getAllComponentFilesFromList.mockRejectedValue( - new Error("Storage error"), - ); - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - - // Act - const result = await fetchUserComponents(); - - // Assert - expect(result).toEqual({ - name: "User Components", - components: [], - folders: [], - isUserFolder: true, - }); - expect(consoleSpy).toHaveBeenCalledWith( - "Error fetching user components:", - expect.any(Error), - ); - - consoleSpy.mockRestore(); - }); - - // todo: test edge cases with malformed component files - }); - describe("fetchUsedComponents", () => { it("should return components used in pipeline tasks", () => { // Arrange diff --git a/src/providers/ComponentLibraryProvider/componentLibrary.ts b/src/providers/ComponentLibraryProvider/componentLibrary.ts index 3e1e377d4..73468d226 100644 --- a/src/providers/ComponentLibraryProvider/componentLibrary.ts +++ b/src/providers/ComponentLibraryProvider/componentLibrary.ts @@ -1,4 +1,3 @@ -import { parseComponentData } from "@/services/componentService"; import type { ComponentFolder, ComponentLibrary, @@ -8,48 +7,6 @@ import type { GraphSpec, TaskSpec, } from "@/utils/componentSpec"; -import { - generateDigest, - getAllComponentFilesFromList, -} from "@/utils/componentStore"; -import { USER_COMPONENTS_LIST_NAME } from "@/utils/constants"; -import { getComponentByUrl } from "@/utils/localforage"; -import { componentSpecToYaml } from "@/utils/yaml"; - -export const fetchUserComponents = async (): Promise => { - try { - const componentFiles = await getAllComponentFilesFromList( - USER_COMPONENTS_LIST_NAME, - ); - - const components: ComponentReference[] = []; - - Array.from(componentFiles.entries()).forEach(([_, fileEntry]) => { - components.push({ - ...fileEntry.componentRef, - name: fileEntry.name, - owned: true, - }); - }); - - const userComponentsFolder: ComponentFolder = { - name: "User Components", - components, - folders: [], - isUserFolder: true, // Add a flag to identify this as user components folder - }; - - return userComponentsFolder; - } catch (error) { - console.error("Error fetching user components:", error); - return { - name: "User Components", - components: [], - folders: [], - isUserFolder: true, - }; - } -}; export const fetchUsedComponents = (graphSpec: GraphSpec): ComponentFolder => { if (!graphSpec || !graphSpec.tasks || typeof graphSpec.tasks !== "object") { @@ -80,71 +37,6 @@ export const fetchUsedComponents = (graphSpec: GraphSpec): ComponentFolder => { }; }; -export async function populateComponentRefs< - T extends ComponentLibrary | ComponentFolder, ->(libraryOrFolder: T): Promise { - async function populateRef( - ref: ComponentReference, - ): Promise { - if (ref.text) { - const parsed = parseComponentData(ref.text); - const digest = await generateDigest(ref.text); - return { - ...ref, - spec: parsed || ref.spec, - digest: digest || ref.digest, - }; - } - - // if there is no text, try to fetch by URL - if (ref.url) { - const stored = await getComponentByUrl(ref.url); - if (stored && stored.data) { - const parsed = parseComponentData(stored.data); - const digest = await generateDigest(stored.data); - return { - ...ref, - spec: parsed || ref.spec, - digest: digest || ref.digest, - text: stored.data, - favorited: stored.favorited || ref.favorited || false, - }; - } - } - - // if there is no url, fallback to spec - if (ref.spec) { - const text = componentSpecToYaml(ref.spec); - const digest = await generateDigest(text); - return { ...ref, text, digest }; - } - - return ref; - } - - // Process components at this level - const updatedComponents = - "components" in libraryOrFolder && Array.isArray(libraryOrFolder.components) - ? await Promise.all(libraryOrFolder.components.map(populateRef)) - : []; - - // Recurse into folders - const updatedFolders = - "folders" in libraryOrFolder && Array.isArray(libraryOrFolder.folders) - ? await Promise.all( - libraryOrFolder.folders.map((folder) => - populateComponentRefs(folder), - ), - ) - : []; - - return { - ...libraryOrFolder, - ...(updatedComponents.length ? { components: updatedComponents } : {}), - ...(updatedFolders.length ? { folders: updatedFolders } : {}), - } as T; -} - export function flattenFolders( folder: ComponentFolder | ComponentLibrary, ): ComponentReference[] { diff --git a/src/providers/ComponentLibraryProvider/libraries/userComponentLibrary.ts b/src/providers/ComponentLibraryProvider/libraries/userComponentLibrary.ts new file mode 100644 index 000000000..84e1e9aaf --- /dev/null +++ b/src/providers/ComponentLibraryProvider/libraries/userComponentLibrary.ts @@ -0,0 +1,54 @@ +import { hydrateComponentReference } from "@/services/componentService"; +import { isHydratedComponentReference } from "@/utils/componentSpec"; +import { getAllUserComponents } from "@/utils/localforage"; + +import { BrowserPersistedLibrary } from "./browserPersistedLibrary"; +import { LibraryDB, type StoredLibraryItem } from "./storage"; + +const USER_COMPONENTS_LIBRARY_ID = "user_components"; + +export class UserComponentsLibrary extends BrowserPersistedLibrary { + constructor() { + super(USER_COMPONENTS_LIBRARY_ID, () => + migrateLegacyUserComponentsFolder(), + ); + } +} + +/** + * Migrate the favorite components to the new database. + * This action supposed to happen only once. + * + * @returns + */ +async function migrateLegacyUserComponentsFolder() { + /** + * Migrate the favorite components to the new database + */ + const allComponents = await getAllUserComponents(); + const userComponents: StoredLibraryItem[] = ( + await Promise.all([ + ...allComponents.map((c) => hydrateComponentReference(c.componentRef)), + ]) + ) + .filter(isHydratedComponentReference) + .map((component) => ({ + digest: component.digest, + name: component.name, + url: component.url, + })); + + await LibraryDB.component_libraries.put({ + id: USER_COMPONENTS_LIBRARY_ID, + name: "User Components", + icon: "Puzzle", + type: "indexdb", + knownDigests: userComponents.map((component) => component.digest), + configuration: { + migrated_at: new Date().toISOString(), + }, + components: userComponents, + }); + + return await LibraryDB.component_libraries.get(USER_COMPONENTS_LIBRARY_ID); +} diff --git a/src/utils/componentStore.ts b/src/utils/componentStore.ts index 41a031ed9..92d7f5ddc 100644 --- a/src/utils/componentStore.ts +++ b/src/utils/componentStore.ts @@ -1,11 +1,8 @@ import localForage from "localforage"; -import { fetchComponentTextFromUrl } from "@/services/componentService"; - import type { DownloadDataType } from "./cache"; import { downloadDataWithCache } from "./cache"; import type { ComponentReference, ComponentSpec } from "./componentSpec"; -import { USER_COMPONENTS_LIST_NAME } from "./constants"; import { getIdOrTitleFromPath } from "./URL"; import { componentSpecFromYaml, componentSpecToYaml } from "./yaml"; @@ -14,8 +11,7 @@ const DB_NAME = "components"; const DIGEST_TO_DATA_DB_TABLE_NAME = "digest_to_component_data"; const DIGEST_TO_COMPONENT_SPEC_DB_TABLE_NAME = "digest_to_component_spec"; const DIGEST_TO_COMPONENT_NAME_DB_TABLE_NAME = "digest_to_component_name"; -const URL_TO_DIGEST_DB_TABLE_NAME = "url_to_digest"; -const DIGEST_TO_CANONICAL_URL_DB_TABLE_NAME = "digest_to_canonical_url"; + const COMPONENT_REF_LISTS_DB_TABLE_NAME = "component_ref_lists"; const COMPONENT_STORE_SETTINGS_DB_TABLE_NAME = "component_store_settings"; const FILE_STORE_DB_TABLE_NAME_PREFIX = "file_store_"; @@ -180,88 +176,6 @@ const storeComponentText = async (componentText: string | ArrayBuffer) => { return componentRef; }; -const storeComponentFromUrl = async ( - url: string, - setUrlAsCanonical = false, -): Promise => { - const urlToDigestDb = localForage.createInstance({ - name: DB_NAME, - storeName: URL_TO_DIGEST_DB_TABLE_NAME, - }); - const digestToComponentSpecDb = localForage.createInstance({ - name: DB_NAME, - storeName: DIGEST_TO_COMPONENT_SPEC_DB_TABLE_NAME, - }); - const digestToDataDb = localForage.createInstance({ - name: DB_NAME, - storeName: DIGEST_TO_DATA_DB_TABLE_NAME, - }); - - const existingDigest = await urlToDigestDb.getItem(url); - if (existingDigest !== null) { - const componentSpec = - await digestToComponentSpecDb.getItem(existingDigest); - const componentData = - await digestToDataDb.getItem(existingDigest); - if (componentSpec !== null && componentData !== null) { - const componentRef: ComponentReferenceWithSpec = { - url: url, - digest: existingDigest, - spec: componentSpec, - text: new TextDecoder().decode(componentData), - }; - return componentRef; - } else { - console.error( - `Component db is corrupted: Component with url ${url} was added before with digest ${existingDigest} but now has no content in the DB.`, - ); - } - } - - // TODO: Think about whether to directly use fetch here. - const componentData = await fetchComponentTextFromUrl(url); - if (!componentData) { - throw new Error(`Failed to fetch component text: ${url}`); - } - const componentRef = await storeComponentText(componentData); - componentRef.url = url; - const digest = componentRef.digest; - if (digest === undefined) { - console.error( - `Cannot happen: storeComponentText has returned componentReference with digest === undefined.`, - ); - return componentRef; - } - if (existingDigest !== null && digest !== existingDigest) { - console.error( - `Component db is corrupted: Component with url ${url} previously had digest ${existingDigest} but now has digest ${digest}.`, - ); - } - const digestToCanonicalUrlDb = localForage.createInstance({ - name: DB_NAME, - storeName: DIGEST_TO_CANONICAL_URL_DB_TABLE_NAME, - }); - const existingCanonicalUrl = - await digestToCanonicalUrlDb.getItem(digest); - if (existingCanonicalUrl === null) { - await digestToCanonicalUrlDb.setItem(digest, url); - } else { - if (url !== existingCanonicalUrl) { - console.debug( - `The component with digest "${digest}" is being loaded from "${url}", but was previously loaded from "${existingCanonicalUrl}".` + - (setUrlAsCanonical ? " Changing the canonical url." : ""), - ); - if (setUrlAsCanonical) { - await digestToCanonicalUrlDb.setItem(digest, url); - } - } - } - // Updating the urlToDigestDb last, because it's used to check for cached entries. - // So we need to be sure that everything has been updated correctly. - await urlToDigestDb.setItem(url, digest); - return componentRef; -}; - interface ComponentFileEntryV2 { componentRef: ComponentReferenceWithSpec; } @@ -274,8 +188,7 @@ interface FileEntry { } interface ComponentFileEntryV3 - extends FileEntry, - ComponentReferenceWithSpecPlusData {} + extends FileEntry, ComponentReferenceWithSpecPlusData {} export type ComponentFileEntry = ComponentFileEntryV3; @@ -350,135 +263,6 @@ export const updateComponentRefInList = async ( return writeComponentRefToFile(listName, fileName, componentRef); }; -const addComponentRefToList = async ( - listName: string, - componentRef: ComponentReferenceWithSpec, - fileName: string = "Component", -) => { - await upgradeSingleComponentListDb(listName); - const tableName = FILE_STORE_DB_TABLE_NAME_PREFIX + listName; - const componentListDb = localForage.createInstance({ - name: DB_NAME, - storeName: tableName, - }); - const existingNames = new Set(await componentListDb.keys()); - const uniqueFileName = makeNameUniqueByAddingIndex(fileName, existingNames); - return writeComponentRefToFile(listName, uniqueFileName, componentRef); -}; - -const addComponentToListByUrl = async ( - listName: string, - url: string, - defaultFileName: string = "Component", - allowDuplicates: boolean = false, - additionalData?: { - [K: string]: any; - }, -) => { - const componentRef = await storeComponentFromUrl(url); - - if (additionalData) { - // Merge additional data into the component reference - Object.assign(componentRef, additionalData); - } - - if (allowDuplicates) { - return addComponentRefToList( - listName, - componentRef, - componentRef.spec.name ?? defaultFileName, - ); - } - - return writeComponentRefToFile( - listName, - componentRef.spec.name ?? defaultFileName, - componentRef, - ); -}; - -const findDuplicateComponent = async ( - listName: string, - componentRef: ComponentReferenceWithSpec, -): Promise => { - try { - const componentFiles = await getAllComponentFilesFromList(listName); - const targetComponentName = componentRef.spec.name; - - if (!targetComponentName) { - return null; // Can't check for duplicates without a name - } - - // Look for components with the same name - for (const [, fileEntry] of componentFiles) { - const existingComponentName = fileEntry.componentRef.spec.name; - - if (existingComponentName === targetComponentName) { - // TODO: This check causing some behavior divergence with ComponentService. - // Found a component with the same name, now check if content is identical - if (fileEntry.componentRef.text === componentRef.text) { - return fileEntry; // Exact duplicate found - } - } - } - - return null; // No duplicate found - } catch (error) { - console.error("Error checking for duplicate component:", error); - return null; - } -}; - -/** - * Enhanced version of addComponentToListByText that checks for duplicates - * @param listName - The component list name - * @param componentText - The component YAML text - * @param fileName - Optional specific file name - * @param defaultFileName - Default file name if none provided - * @param allowDuplicates - Whether to allow duplicate imports (default: false) - * @returns Object with the file entry and a flag indicating if it was a duplicate - */ -const addComponentToListByTextWithDuplicateCheck = async ( - listName: string, - componentText: string | ArrayBuffer, - fileName?: string, - defaultFileName: string = "Component", - allowDuplicates: boolean = false, - additionalData?: { - [K: string]: any; - }, -): Promise => { - const componentRef = await storeComponentText(componentText); - - if (additionalData) { - // Merge additional data into the component reference - Object.assign(componentRef, additionalData); - } - - if (!allowDuplicates) { - const existingComponent = await findDuplicateComponent( - listName, - componentRef, - ); - if (existingComponent) { - return updateComponentRefInList( - listName, - componentRef, - existingComponent.name, - ); - } - } - - // No duplicate found or duplicates are allowed, proceed with normal addition - const fileEntry = await addComponentRefToList( - listName, - componentRef, - fileName ?? componentRef.spec.name ?? defaultFileName, - ); - - return fileEntry; -}; - export const writeComponentToFileListFromText = async ( listName: string, fileName: string, @@ -715,42 +499,3 @@ const upgradeSingleComponentListDb = async (listName: string) => { ); } }; - -export const importComponent = async (component: ComponentReference) => { - if (!component.url) { - component.favorited = true; - - if (component.spec && component.name) { - return await writeComponentRefToFile( - USER_COMPONENTS_LIST_NAME, - component.name, - component as ComponentReferenceWithSpec, - ); - } else if (component.text) { - return await addComponentToListByTextWithDuplicateCheck( - USER_COMPONENTS_LIST_NAME, - component.text, - component.name, - "Component", - false, - { favorited: true }, - ); - } else { - console.warn( - `Component "${component.name}" does not have spec or text, cannot favorite.`, - ); - } - - return; - } - - return await addComponentToListByUrl( - USER_COMPONENTS_LIST_NAME, - component.url, - component.name, - false, - { - favorited: true, - }, - ); -}; diff --git a/src/utils/localforage.ts b/src/utils/localforage.ts index 29e5689a9..b440dbf2e 100644 --- a/src/utils/localforage.ts +++ b/src/utils/localforage.ts @@ -107,12 +107,6 @@ export async function getAllUserComponents(): Promise { return userComponents; } -export async function getUserComponentByName( - name: string, -): Promise { - return await userComponentStore.getItem(name); -} - // App Settings export const getUserBackendUrl = async () => { return (await settingsStore.getItem("userBackendUrl")) ?? "";