Jacob Corn e1d3a247c7
feat: random sort option for front page (#2363)
* Add hook for random sorting

* Add random sorting to front page

* Add multiple tests for random sorting.

* Be extra sure that all recipes are returned.

* Too stable random. seed doesn't reach backend.

* add timestamp to useRecipeSearch

* Update randomization tests for timestamp seeding

* ruff cleanup

* pass timestamp separately in getAll

* remove debugging log items

* remove timestamp from address bar

* remove defaults from backend timestamps

* timestamp should be optional

* fix edge case: query without timestamp

* similar edge case: no timestamp in pagination

* ruff :/

* better edge case handling

* stabilize random search test w/more recipes

* better pagination seeding

* update pagination seed test

* remove redundant random/seed check

* Test for api routes to random sorting.

* please the typing gods

* hack to make query parameters throw correct exc

* ruff

* fix validator message typo

* black reformatting

---------

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2023-05-29 16:56:20 -08:00

198 lines
6.5 KiB
TypeScript

import { BaseCRUDAPI } from "../../base/base-clients";
import { route } from "../../base";
import { CommentsApi } from "./recipe-comments";
import { RecipeShareApi } from "./recipe-share";
import {
Recipe,
CreateRecipe,
RecipeAsset,
CreateRecipeByUrlBulk,
ParsedIngredient,
UpdateImageResponse,
RecipeZipTokenResponse,
RecipeLastMade,
RecipeTimelineEventIn,
RecipeTimelineEventOut,
RecipeTimelineEventUpdate,
} from "~/lib/api/types/recipe";
import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
export type Parser = "nlp" | "brute";
export interface CreateAsset {
name: string;
icon: string;
extension: string;
file: File;
}
const prefix = "/api";
const routes = {
recipesCreate: `${prefix}/recipes/create`,
recipesBase: `${prefix}/recipes`,
recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`,
recipesCreateUrl: `${prefix}/recipes/create-url`,
recipesCreateUrlBulk: `${prefix}/recipes/create-url/bulk`,
recipesCreateFromZip: `${prefix}/recipes/create-from-zip`,
recipesCategory: `${prefix}/recipes/category`,
recipesParseIngredient: `${prefix}/parser/ingredient`,
recipesParseIngredients: `${prefix}/parser/ingredients`,
recipesCreateFromOcr: `${prefix}/recipes/create-ocr`,
recipesTimelineEvent: `${prefix}/recipes/timeline/events`,
recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugExport: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports`,
recipesRecipeSlugExportZip: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports/zip`,
recipesRecipeSlugImage: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/image`,
recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`,
recipesSlugComments: (slug: string) => `${prefix}/recipes/${slug}/comments`,
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
recipesSlugLastMade: (slug: string) => `${prefix}/recipes/${slug}/last-made`,
recipesTimelineEventId: (id: string) => `${prefix}/recipes/timeline/events/${id}`,
};
export type RecipeSearchQuery = {
search: string;
orderDirection?: "asc" | "desc";
groupId?: string;
queryFilter?: string;
cookbook?: string;
categories?: string[];
requireAllCategories?: boolean;
tags?: string[];
requireAllTags?: boolean;
tools?: string[];
requireAllTools?: boolean;
foods?: string[];
requireAllFoods?: boolean;
page?: number;
perPage?: number;
orderBy?: string;
_searchSeed?: string;
};
export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
baseRoute: string = routes.recipesBase;
itemRoute = routes.recipesRecipeSlug;
comments: CommentsApi;
share: RecipeShareApi;
constructor(requests: ApiRequestInstance) {
super(requests);
this.comments = new CommentsApi(requests);
this.share = new RecipeShareApi(requests);
}
async search(rsq: RecipeSearchQuery) {
return await this.requests.get<PaginationData<Recipe>>(route(routes.recipesBase, rsq));
}
async getAllByCategory(categories: string[]) {
return await this.requests.get<Recipe[]>(routes.recipesCategory, {
categories,
});
}
async createAsset(recipeSlug: string, payload: CreateAsset) {
const formData = new FormData();
formData.append("file", payload.file);
formData.append("name", payload.name);
formData.append("extension", payload.extension);
formData.append("icon", payload.icon);
return await this.requests.post<RecipeAsset>(routes.recipesRecipeSlugAssets(recipeSlug), formData);
}
updateImage(slug: string, fileObject: File) {
const formData = new FormData();
formData.append("image", fileObject);
formData.append("extension", fileObject.name.split(".").pop() ?? "");
return this.requests.put<UpdateImageResponse, FormData>(routes.recipesRecipeSlugImage(slug), formData);
}
updateImagebyURL(slug: string, url: string) {
return this.requests.post<UpdateImageResponse>(routes.recipesRecipeSlugImage(slug), { url });
}
async testCreateOneUrl(url: string) {
return await this.requests.post<Recipe | null>(routes.recipesTestScrapeUrl, { url });
}
async createOneByUrl(url: string, includeTags: boolean) {
return await this.requests.post<string>(routes.recipesCreateUrl, { url, includeTags });
}
async createManyByUrl(payload: CreateRecipeByUrlBulk) {
return await this.requests.post<string>(routes.recipesCreateUrlBulk, payload);
}
async parseIngredients(parser: Parser, ingredients: Array<string>) {
parser = parser || "nlp";
return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients });
}
async parseIngredient(parser: Parser, ingredient: string) {
parser = parser || "nlp";
return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient });
}
async getZipToken(recipeSlug: string) {
return await this.requests.post<RecipeZipTokenResponse>(routes.recipesRecipeSlugExport(recipeSlug), {});
}
getZipRedirectUrl(recipeSlug: string, token: string) {
return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`;
}
async createFromOcr(file: File, makeFileRecipeImage: boolean) {
const formData = new FormData();
formData.append("file", file);
formData.append("extension", file.name.split(".").pop() ?? "");
formData.append("makefilerecipeimage", String(makeFileRecipeImage));
return await this.requests.post(routes.recipesCreateFromOcr, formData);
}
async updateLastMade(recipeSlug: string, timestamp: string) {
return await this.requests.patch<Recipe, RecipeLastMade>(routes.recipesSlugLastMade(recipeSlug), { timestamp })
}
async createTimelineEvent(payload: RecipeTimelineEventIn) {
return await this.requests.post<RecipeTimelineEventOut>(routes.recipesTimelineEvent, payload);
}
async updateTimelineEvent(eventId: string, payload: RecipeTimelineEventUpdate) {
return await this.requests.put<RecipeTimelineEventOut, RecipeTimelineEventUpdate>(
routes.recipesTimelineEventId(eventId),
payload
);
}
async deleteTimelineEvent(eventId: string) {
return await this.requests.delete<RecipeTimelineEventOut>(routes.recipesTimelineEventId(eventId));
}
async getAllTimelineEvents(page = 1, perPage = -1, params = {} as any) {
return await this.requests.get<PaginationData<RecipeTimelineEventOut>>(
routes.recipesTimelineEvent,
{
params: { page, perPage, ...params },
}
);
}
}