Skip to content

Commit

Permalink
Deck catalog: category filter (#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
kubk authored Dec 14, 2023
1 parent 6bbcc65 commit c14155b
Show file tree
Hide file tree
Showing 13 changed files with 130 additions and 16 deletions.
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"luxon": "^3.4.3",
"mobx": "^6.10.2",
"mobx-log": "^2.2.3",
"mobx-persist-store": "^1.1.3",
"mobx-react-lite": "^4.0.5",
"mobx-utils": "^6.0.8",
"react": "^18.2.0",
Expand Down
5 changes: 5 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { DeckCatalogResponse } from "../../functions/catalog-decks.ts";
import { DeckWithCardsResponse } from "../../functions/deck-with-cards.ts";
import { CopyDeckResponse } from "../../functions/duplicate-deck.ts";
import { DeckCategoryResponse } from "../../functions/deck-categories.ts";

export const healthRequest = () => {
return request<HealthResponse>("/health");
Expand Down Expand Up @@ -94,3 +95,7 @@ export const apiDeckCatalog = () => {
export const apiDeckWithCards = (deckId: number) => {
return request<DeckWithCardsResponse>(`/deck-with-cards?deck_id=${deckId}`);
};

export const apiDeckCategories = () => {
return request<DeckCategoryResponse>("/deck-categories");
};
24 changes: 24 additions & 0 deletions src/lib/cache/cache-promise.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test, vi, expect } from "vitest";
import { cachePromise } from "./cache-promise.ts";

test("should cache the resolved value of a promise", async () => {
const mockFunction = vi.fn();
mockFunction.mockResolvedValueOnce("Cached value");

const promise = new Promise<string>((resolve) => {
resolve(mockFunction());
});

const cached = cachePromise<string>();

// First call, should invoke the promise
const result1 = await cached(promise);
expect(result1).toBe("Cached value");
expect(mockFunction).toHaveBeenCalledTimes(1);

// Second call, should use cached value
const result2 = await cached(promise);
expect(result2).toBe("Cached value");
// The mock function should not have been called again
expect(mockFunction).toHaveBeenCalledTimes(1);
});
14 changes: 14 additions & 0 deletions src/lib/cache/cache-promise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const cachePromise = <T>() => {
let cache: T | null = null;
let isCacheSet = false;

return async function (promise: Promise<T>): Promise<T> {
if (isCacheSet) {
return cache as T;
}

cache = await promise;
isCacheSet = true;
return cache;
};
};
16 changes: 16 additions & 0 deletions src/lib/mobx-form/persistable-field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { TextField } from "./mobx-form.ts";
import { makePersistable } from "mobx-persist-store";

export const persistableField = <T>(
field: TextField<T>,
storageKey: string,
): TextField<T> => {
makePersistable(field, {
name: storageKey,
properties: ["value"],
storage: window.localStorage,
expireIn: 86400000, // One day in milliseconds
});

return field;
};
16 changes: 9 additions & 7 deletions src/screens/deck-catalog/deck-added-label.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { css } from "@emotion/css";
import { css, cx } from "@emotion/css";
import { theme } from "../../ui/theme.tsx";
import React from "react";

Expand All @@ -9,16 +9,18 @@ export const DeckAddedLabel = () => {
position: "absolute",
right: 0,
top: 0,
fontSize: 14,
fontStyle: "normal",
padding: "0 8px",
borderRadius: theme.borderRadius,
backgroundColor: theme.secondaryBgColor,
border: "1px solid " + theme.linkColor,
color: theme.linkColor,
})}
>
ADDED
<i
className={cx(
"mdi mdi-check-circle",
css({
color: theme.linkColor,
}),
)}
/>
</div>
);
};
21 changes: 20 additions & 1 deletion src/screens/deck-catalog/deck-catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const DeckCatalog = observer(() => {
>
<h3 className={css({ textAlign: "center" })}>Deck Catalog</h3>
<div className={css({ display: "flex", gap: 4 })}>
<div className={css({ color: theme.hintColor })}>Available in:</div>
<div className={css({ color: theme.hintColor })}>Available in</div>
<Select<LanguageFilter>
value={store.filters.language.value}
onChange={store.filters.language.onChange}
Expand All @@ -50,6 +50,25 @@ export const DeckCatalog = observer(() => {
/>
</div>

<div className={css({ display: "flex", gap: 4 })}>
<div className={css({ color: theme.hintColor })}>Category</div>
<Select
value={store.filters.categoryId.value}
onChange={store.filters.categoryId.onChange}
isLoading={store.categories?.state === "pending"}
options={
store.categories?.state === "fulfilled"
? [{ value: "", label: "Any" }].concat(
store.categories.value.categories.map((category) => ({
value: category.id,
label: category.name,
})),
)
: []
}
/>
</div>

{(() => {
if (store.decks?.state === "pending") {
return range(5).map((i) => <DeckLoading key={i} />);
Expand Down
24 changes: 18 additions & 6 deletions src/store/deck-catalog-store.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { makeAutoObservable } from "mobx";
import { apiDeckCatalog } from "../api/api.ts";
import { apiDeckCatalog, apiDeckCategories } from "../api/api.ts";
import { fromPromise, IPromiseBasedObservable } from "mobx-utils";
import { DeckCatalogResponse } from "../../functions/catalog-decks.ts";
import { TextField } from "../lib/mobx-form/mobx-form.ts";
import { cachePromise } from "../lib/cache/cache-promise.ts";
import { DeckCategoryResponse } from "../../functions/deck-categories.ts";
import { persistableField } from "../lib/mobx-form/persistable-field.ts";

export enum LanguageFilter {
Any = "any",
Expand All @@ -11,18 +14,24 @@ export enum LanguageFilter {
Russian = "ru",
}

const decksCached = cachePromise<DeckCatalogResponse>();
const categoriesCached = cachePromise<DeckCategoryResponse>();

export class DeckCatalogStore {
decks?: IPromiseBasedObservable<DeckCatalogResponse>;
filters = {
language: new TextField(LanguageFilter.Any),
language: persistableField(new TextField(LanguageFilter.Any), "catalogLn"),
categoryId: persistableField(new TextField<string>(""), "catalogCateg"),
};
categories?: IPromiseBasedObservable<DeckCategoryResponse>;

constructor() {
makeAutoObservable(this, {}, { autoBind: true });
}

load() {
this.decks = fromPromise(apiDeckCatalog());
this.decks = fromPromise(decksCached(apiDeckCatalog()));
this.categories = fromPromise(categoriesCached(apiDeckCategories()));
}

get filteredDecks() {
Expand All @@ -31,14 +40,17 @@ export class DeckCatalogStore {
}

const language = this.filters.language.value;
const categoryId = this.filters.categoryId.value;

return this.decks.value.decks.filter((deck) => {
if (language === LanguageFilter.Any) {
return true;
if (language !== LanguageFilter.Any && deck.available_in !== language) {
return false;
}
if (deck.available_in !== language) {

if (!!categoryId && deck.category_id !== categoryId) {
return false;
}

return true;
});
}
Expand Down
4 changes: 3 additions & 1 deletion src/store/deck-form-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const mapUpsertDeckRequestToResponse = (
is_public: false,
speak_locale: null,
speak_field: null,
deck_category: null,
category_id: null,
deck_card: input.cards.map((card) => {
assert(input.id);
return {
Expand Down Expand Up @@ -99,7 +101,7 @@ vi.mock("./deck-list-store.ts", () => {
searchDeckById: (id: number) => {
return myDecks.find((deck) => deck.id === id);
},
myDecks: myDecks
myDecks: myDecks,
},
};
});
Expand Down
4 changes: 4 additions & 0 deletions src/store/deck-list-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ vi.mock("../api/api.ts", () => {
description: "",
speak_locale: null,
speak_field: null,
category_id: null,
deck_category: null,
deck_card: [
{
id: 1,
Expand Down Expand Up @@ -84,6 +86,8 @@ vi.mock("../api/api.ts", () => {
description: "",
speak_locale: null,
speak_field: null,
deck_category: null,
category_id: null,
deck_card: [
{
id: 4,
Expand Down
2 changes: 2 additions & 0 deletions src/store/review-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const deckMock: DeckWithCardsWithReviewType = {
share_id: null,
is_public: false,
available_in: null,
deck_category: null,
category_id: null,
};

vi.mock("../api/api.ts", () => {
Expand Down
6 changes: 5 additions & 1 deletion src/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ type Props<T extends string | number> = {
value: string;
onChange: (newValue: T) => void;
options: Option<T>[];
isLoading?: boolean;
};

export const Select = <T extends string | number>(props: Props<T>) => {
const { value, onChange, options } = props;
const { value, onChange, options, isLoading } = props;
if (isLoading) {
return <div className={css({ color: theme.hintColor })}>Loading...</div>;
}

return (
<select
Expand Down

0 comments on commit c14155b

Please sign in to comment.