Skip to content
Merged
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
1 change: 1 addition & 0 deletions react-compiler.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [
"src/hooks/useHandleEdgeSelection.ts",
"src/hooks/useEdgeSelectionHighlight.ts",
"src/hooks/useRunSearchParams.ts",
"src/hooks/useFavorites.ts",

"src/components/shared/Tags",
"src/components/shared/Submitters/Oasis/components",
Expand Down
97 changes: 97 additions & 0 deletions src/hooks/useFavorites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import "fake-indexeddb/auto";

import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

import { LibraryDB } from "@/providers/ComponentLibraryProvider/libraries/storage";

import { type FavoriteItem, useFavorites } from "./useFavorites";

const pipeline: FavoriteItem = {
type: "pipeline",
id: "p1",
name: "My Pipeline",
};
const run: FavoriteItem = { type: "run", id: "r1", name: "My Run" };

afterEach(async () => {
await LibraryDB.favorites.clear();
});

describe("useFavorites", () => {
it("starts with no favorites", async () => {
const { result } = renderHook(() => useFavorites());
await waitFor(() => {
expect(result.current.favorites).toEqual([]);
});
});

it("adds a favorite", async () => {
const { result } = renderHook(() => useFavorites());
await result.current.addFavorite(pipeline);
await waitFor(() => {
expect(result.current.favorites).toEqual([pipeline]);
});
});

it("does not add a duplicate favorite", async () => {
const { result } = renderHook(() => useFavorites());
await result.current.addFavorite(pipeline);
await result.current.addFavorite(pipeline);
await waitFor(() => {
expect(result.current.favorites).toHaveLength(1);
});
});

it("removes a favorite", async () => {
const { result } = renderHook(() => useFavorites());
await result.current.addFavorite(pipeline);
await result.current.addFavorite(run);
await result.current.removeFavorite("pipeline", "p1");
await waitFor(() => {
expect(result.current.favorites).toEqual([run]);
});
});

it("toggles a favorite on and off", async () => {
const { result } = renderHook(() => useFavorites());
await result.current.toggleFavorite(pipeline);
await waitFor(() => {
expect(result.current.favorites).toEqual([pipeline]);
});
await result.current.toggleFavorite(pipeline);
await waitFor(() => {
expect(result.current.favorites).toEqual([]);
});
});

it("correctly reports isFavorite", async () => {
const { result } = renderHook(() => useFavorites());
await result.current.addFavorite(pipeline);
await waitFor(() => {
expect(result.current.isFavorite("pipeline", "p1")).toBe(true);
expect(result.current.isFavorite("run", "r1")).toBe(false);
});
});

it("does not confuse items of different types with the same id", async () => {
const pipelineItem: FavoriteItem = {
type: "pipeline",
id: "1",
name: "Pipeline",
};
const runItem: FavoriteItem = { type: "run", id: "1", name: "Run" };
const { result } = renderHook(() => useFavorites());

await result.current.addFavorite(pipelineItem);
await waitFor(() => {
expect(result.current.isFavorite("pipeline", "1")).toBe(true);
expect(result.current.isFavorite("run", "1")).toBe(false);
});

await result.current.addFavorite(runItem);
await waitFor(() => {
expect(result.current.favorites).toHaveLength(2);
});
});
});
35 changes: 35 additions & 0 deletions src/hooks/useFavorites.ts
Comment thread
Mbeaulne marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useLiveQuery } from "dexie-react-hooks";

import {
type FavoriteItem,
type FavoriteType,
LibraryDB,
} from "@/providers/ComponentLibraryProvider/libraries/storage";

export type { FavoriteItem, FavoriteType };

export function useFavorites() {
const favorites = useLiveQuery(() => LibraryDB.favorites.toArray(), []) ?? [];

const addFavorite = async (item: FavoriteItem) => {
// put is an upsert — compound PK [type+id] prevents duplicates
await LibraryDB.favorites.put(item);
};

const removeFavorite = async (type: FavoriteType, id: string) => {
await LibraryDB.favorites.delete([type, id]);
};

const isFavorite = (type: FavoriteType, id: string) =>
favorites.some((f) => f.type === type && f.id === id);

const toggleFavorite = async (item: FavoriteItem) => {
if (isFavorite(item.type, item.id)) {
await removeFavorite(item.type, item.id);
} else {
await addFavorite(item);
}
};

return { favorites, addFavorite, removeFavorite, toggleFavorite, isFavorite };
Copy link
Copy Markdown
Collaborator

@camielvs camielvs Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some things are being caught by knip. Presumably they will be consumed later in the stack. For completeness (and if we ever need to roll back) it may be worth adding the file to knip.json -> ignore and then removing as appropriate.

}
16 changes: 14 additions & 2 deletions src/providers/ComponentLibraryProvider/libraries/storage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Dexie, { type EntityTable } from "dexie";
import Dexie, { type EntityTable, type Table } from "dexie";
import { icons } from "lucide-react";

const DB_NAME = "oasis-app";
Expand Down Expand Up @@ -31,15 +31,27 @@ export interface StoredLibrary extends StoredLibraryFolder {
knownDigests: string[];
}

export type FavoriteType = "pipeline" | "run";

export interface FavoriteItem {
type: FavoriteType;
id: string;
name: string;
}

export const LibraryDB = new Dexie(DB_NAME) as Dexie & {
component_libraries: EntityTable<StoredLibrary, "id">;
favorites: Table<FavoriteItem, [FavoriteType, string]>;
};

/**
* Initialize the database with the favorite components
* Each version should be declared in DEXIE_EPOCH + {number}, starting from 1
*/
LibraryDB.version(DEXIE_EPOCH + 1).stores({
// id - primary key; name is unique index
component_libraries: "id, &name",
});

LibraryDB.version(DEXIE_EPOCH + 2).stores({
favorites: "[type+id]",
});
Loading