diff --git a/frontend/components/Domain/Recipe/RecipeCardSection.vue b/frontend/components/Domain/Recipe/RecipeCardSection.vue index d065e0e80d6..62ca93d5431 100644 --- a/frontend/components/Domain/Recipe/RecipeCardSection.vue +++ b/frontend/components/Domain/Recipe/RecipeCardSection.vue @@ -69,50 +69,52 @@ @toggle-dense-view="toggleMobileCards()" /> </v-app-bar> - <div v-if="recipes" class="mt-2"> - <v-row v-if="!useMobileCards"> - <v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3"> - <v-lazy> - <RecipeCard - :name="recipe.name" - :description="recipe.description" - :slug="recipe.slug" - :rating="recipe.rating" - :image="recipe.image" - :tags="recipe.tags" - :recipe-id="recipe.id" - /> - </v-lazy> - </v-col> - </v-row> - <v-row v-else dense> - <v-col - v-for="recipe in recipes" - :key="recipe.name" - cols="12" - :sm="singleColumn ? '12' : '12'" - :md="singleColumn ? '12' : '6'" - :lg="singleColumn ? '12' : '4'" - :xl="singleColumn ? '12' : '3'" - > - <v-lazy> - <RecipeCardMobile - :name="recipe.name" - :description="recipe.description" - :slug="recipe.slug" - :rating="recipe.rating" - :image="recipe.image" - :tags="recipe.tags" - :recipe-id="recipe.id" - /> - </v-lazy> - </v-col> - </v-row> + <div v-if="recipes && ready"> + <div class="mt-2"> + <v-row v-if="!useMobileCards"> + <v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3"> + <v-lazy> + <RecipeCard + :name="recipe.name" + :description="recipe.description" + :slug="recipe.slug" + :rating="recipe.rating" + :image="recipe.image" + :tags="recipe.tags" + :recipe-id="recipe.id" + /> + </v-lazy> + </v-col> + </v-row> + <v-row v-else dense> + <v-col + v-for="recipe in recipes" + :key="recipe.name" + cols="12" + :sm="singleColumn ? '12' : '12'" + :md="singleColumn ? '12' : '6'" + :lg="singleColumn ? '12' : '4'" + :xl="singleColumn ? '12' : '3'" + > + <v-lazy> + <RecipeCardMobile + :name="recipe.name" + :description="recipe.description" + :slug="recipe.slug" + :rating="recipe.rating" + :image="recipe.image" + :tags="recipe.tags" + :recipe-id="recipe.id" + /> + </v-lazy> + </v-col> + </v-row> + </div> + <v-card v-intersect="infiniteScroll"></v-card> + <v-fade-transition> + <AppLoader v-if="loading" :loading="loading" /> + </v-fade-transition> </div> - <v-card v-intersect="infiniteScroll"></v-card> - <v-fade-transition> - <AppLoader v-if="loading" :loading="loading" /> - </v-fade-transition> </div> </template> @@ -223,36 +225,42 @@ export default defineComponent({ const queryFilter = computed(() => { const orderBy = props.query?.orderBy || preferences.value.orderBy; - return preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null; + const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null; + + if (props.query.queryFilter && orderByFilter) { + return `(${props.query.queryFilter}) AND ${orderByFilter}`; + } else if (props.query.queryFilter) { + return props.query.queryFilter; + } else { + return orderByFilter; + } }); async function fetchRecipes(pageCount = 1) { return await fetchMore( page.value, - // we double-up the first call to avoid a bug with large screens that render the entire first page without scrolling, preventing additional loading perPage * pageCount, props.query?.orderBy || preferences.value.orderBy, props.query?.orderDirection || preferences.value.orderDirection, props.query, - // filter out recipes that have a null value for the property we're sorting by + // we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by queryFilter.value ); } onMounted(async () => { - if (props.query) { - await initRecipes(); - ready.value = true; - } + await initRecipes(); + ready.value = true; }); - let lastQuery: string | undefined; + let lastQuery: string | undefined = JSON.stringify(props.query); watch( () => props.query, async (newValue: RecipeSearchQuery | undefined) => { const newValueString = JSON.stringify(newValue) - if (newValue && (!ready.value || lastQuery !== newValueString)) { + if (lastQuery !== newValueString) { lastQuery = newValueString; + ready.value = false; await initRecipes(); ready.value = true; } @@ -261,8 +269,12 @@ export default defineComponent({ async function initRecipes() { page.value = 1; - const newRecipes = await fetchRecipes(2); - if (!newRecipes.length) { + hasMore.value = true; + + // we double-up the first call to avoid a bug with large screens that render + // the entire first page without scrolling, preventing additional loading + const newRecipes = await fetchRecipes(page.value + 1); + if (newRecipes.length < perPage) { hasMore.value = false; } @@ -274,7 +286,7 @@ export default defineComponent({ const infiniteScroll = useThrottleFn(() => { useAsync(async () => { - if (!ready.value || !hasMore.value || loading.value) { + if (!hasMore.value || loading.value) { return; } @@ -282,9 +294,10 @@ export default defineComponent({ page.value = page.value + 1; const newRecipes = await fetchRecipes(); - if (!newRecipes.length) { + if (newRecipes.length < perPage) { hasMore.value = false; - } else { + } + if (newRecipes.length) { context.emit(APPEND_RECIPES_EVENT, newRecipes); } @@ -379,6 +392,7 @@ export default defineComponent({ displayTitleIcon, EVENTS, infiniteScroll, + ready, loading, navigateRandom, preferences, diff --git a/frontend/components/Domain/Recipe/RecipeDataTable.vue b/frontend/components/Domain/Recipe/RecipeDataTable.vue index fd8900004ce..152b08d06c6 100644 --- a/frontend/components/Domain/Recipe/RecipeDataTable.vue +++ b/frontend/components/Domain/Recipe/RecipeDataTable.vue @@ -3,6 +3,8 @@ v-model="selected" item-key="id" show-select + sort-by="dateAdded" + sort-desc :headers="headers" :items="recipes" :items-per-page="15" @@ -39,6 +41,9 @@ </v-list-item-content> </v-list-item> </template> + <template #item.dateAdded="{ item }"> + {{ formatDate(item.dateAdded) }} + </template> </v-data-table> </template> @@ -132,6 +137,14 @@ export default defineComponent({ return hdrs; }); + function formatDate(date: string) { + try { + return i18n.d(Date.parse(date), "medium"); + } catch { + return ""; + } + } + // ============ // Group Members const api = useUserApi(); @@ -160,6 +173,7 @@ export default defineComponent({ groupSlug, setValue, headers, + formatDate, members, getMember, }; diff --git a/frontend/components/Domain/Recipe/RecipeExplorerPage.vue b/frontend/components/Domain/Recipe/RecipeExplorerPage.vue index 03cb0f7f213..a9c212dff57 100644 --- a/frontend/components/Domain/Recipe/RecipeExplorerPage.vue +++ b/frontend/components/Domain/Recipe/RecipeExplorerPage.vue @@ -53,6 +53,14 @@ {{ $t("general.foods") }} </SearchFilter> + <!-- Household Filter --> + <SearchFilter v-if="households.length > 1" v-model="selectedHouseholds" :items="households" radio> + <v-icon left> + {{ $globals.icons.household }} + </v-icon> + {{ $t("household.households") }} + </SearchFilter> + <!-- Sort Options --> <v-menu offset-y nudge-bottom="3"> <template #activator="{ on, attrs }"> @@ -142,17 +150,25 @@ import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref, import { watchDebounced } from "@vueuse/shared"; import SearchFilter from "~/components/Domain/SearchFilter.vue"; import { useLoggedInState } from "~/composables/use-logged-in-state"; -import { useCategoryStore, useFoodStore, useTagStore, useToolStore } from "~/composables/store"; +import { + useCategoryStore, + usePublicCategoryStore, + useFoodStore, + usePublicFoodStore, + useHouseholdStore, + usePublicHouseholdStore, + useTagStore, + usePublicTagStore, + useToolStore, + usePublicToolStore, +} from "~/composables/store"; import { useUserSearchQuerySession } from "~/composables/use-users/preferences"; import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe"; import { NoUndefinedField } from "~/lib/api/types/non-generated"; import { useLazyRecipes } from "~/composables/recipes"; import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe"; -import { usePublicCategoryStore } from "~/composables/store/use-category-store"; -import { usePublicFoodStore } from "~/composables/store/use-food-store"; -import { usePublicTagStore } from "~/composables/store/use-tag-store"; -import { usePublicToolStore } from "~/composables/store/use-tool-store"; +import { HouseholdSummary } from "~/lib/api/types/household"; export default defineComponent({ components: { SearchFilter, RecipeCardSection }, @@ -186,6 +202,9 @@ export default defineComponent({ const foods = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value); const selectedFoods = ref<IngredientFood[]>([]); + const households = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value); + const selectedHouseholds = ref([] as NoUndefinedField<HouseholdSummary>[]); + const tags = isOwnGroup.value ? useTagStore() : usePublicTagStore(groupSlug.value); const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]); @@ -199,6 +218,7 @@ export default defineComponent({ search: state.value.search ? state.value.search : "", categories: toIDArray(selectedCategories.value), foods: toIDArray(selectedFoods.value), + households: toIDArray(selectedHouseholds.value), tags: toIDArray(selectedTags.value), tools: toIDArray(selectedTools.value), requireAllCategories: state.value.requireAllCategories, @@ -239,10 +259,9 @@ export default defineComponent({ state.value.requireAllFoods = queryDefaults.requireAllFoods; selectedCategories.value = []; selectedFoods.value = []; + selectedHouseholds.value = []; selectedTags.value = []; selectedTools.value = []; - - search(); } function toggleOrderDirection() { @@ -280,6 +299,7 @@ export default defineComponent({ search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search, orderBy: passedQuery.value.orderBy === queryDefaults.orderBy ? undefined : passedQuery.value.orderBy, orderDirection: passedQuery.value.orderDirection === queryDefaults.orderDirection ? undefined : passedQuery.value.orderDirection, + households: !passedQuery.value.households?.length || passedQuery.value.households?.length === households.store.value.length ? undefined : passedQuery.value.households, requireAllCategories: passedQuery.value.requireAllCategories ? "true" : undefined, requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined, requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined, @@ -361,13 +381,10 @@ export default defineComponent({ watch( () => route.value.query, () => { - if (state.value.ready) { - hydrateSearch(); + if (!Object.keys(route.value.query).length) { + reset(); } - }, - { - deep: true, - }, + } ) async function hydrateSearch() { @@ -423,9 +440,9 @@ export default defineComponent({ if (query.categories?.length) { promises.push( waitUntilAndExecute( - () => categories.items.value.length > 0, + () => categories.store.value.length > 0, () => { - const result = categories.items.value.filter((item) => + const result = categories.store.value.filter((item) => (query.categories as string[]).includes(item.id as string) ); @@ -440,9 +457,9 @@ export default defineComponent({ if (query.tags?.length) { promises.push( waitUntilAndExecute( - () => tags.items.value.length > 0, + () => tags.store.value.length > 0, () => { - const result = tags.items.value.filter((item) => (query.tags as string[]).includes(item.id as string)); + const result = tags.store.value.filter((item) => (query.tags as string[]).includes(item.id as string)); selectedTags.value = result as NoUndefinedField<RecipeTag>[]; } ) @@ -454,9 +471,9 @@ export default defineComponent({ if (query.tools?.length) { promises.push( waitUntilAndExecute( - () => tools.items.value.length > 0, + () => tools.store.value.length > 0, () => { - const result = tools.items.value.filter((item) => (query.tools as string[]).includes(item.id)); + const result = tools.store.value.filter((item) => (query.tools as string[]).includes(item.id)); selectedTools.value = result as NoUndefinedField<RecipeTool>[]; } ) @@ -469,13 +486,13 @@ export default defineComponent({ promises.push( waitUntilAndExecute( () => { - if (foods.foods.value) { - return foods.foods.value.length > 0; + if (foods.store.value) { + return foods.store.value.length > 0; } return false; }, () => { - const result = foods.foods.value?.filter((item) => (query.foods as string[]).includes(item.id)); + const result = foods.store.value?.filter((item) => (query.foods as string[]).includes(item.id)); selectedFoods.value = result ?? []; } ) @@ -484,6 +501,25 @@ export default defineComponent({ selectedFoods.value = []; } + if (query.households?.length) { + promises.push( + waitUntilAndExecute( + () => { + if (households.store.value) { + return households.store.value.length > 0; + } + return false; + }, + () => { + const result = households.store.value?.filter((item) => (query.households as string[]).includes(item.id)); + selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? []; + } + ) + ); + } else { + selectedHouseholds.value = []; + } + await Promise.allSettled(promises); }; @@ -515,6 +551,7 @@ export default defineComponent({ () => state.value.orderDirection, selectedCategories, selectedFoods, + selectedHouseholds, selectedTags, selectedTools, ], @@ -533,10 +570,11 @@ export default defineComponent({ search, reset, state, - categories: categories.items as unknown as NoUndefinedField<RecipeCategory>[], - tags: tags.items as unknown as NoUndefinedField<RecipeTag>[], - foods: foods.foods, - tools: tools.items as unknown as NoUndefinedField<RecipeTool>[], + categories: categories.store as unknown as NoUndefinedField<RecipeCategory>[], + tags: tags.store as unknown as NoUndefinedField<RecipeTag>[], + foods: foods.store, + tools: tools.store as unknown as NoUndefinedField<RecipeTool>[], + households: households.store as unknown as NoUndefinedField<HouseholdSummary>[], sortable, toggleOrderDirection, @@ -545,6 +583,7 @@ export default defineComponent({ selectedCategories, selectedFoods, + selectedHouseholds, selectedTags, selectedTools, appendRecipes, diff --git a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue index f1e8e54f830..cdf37107897 100644 --- a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue +++ b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue @@ -289,11 +289,11 @@ export default defineComponent({ createAssignFood, unitAutocomplete, createAssignUnit, - foods: foodStore.foods, + foods: foodStore.store, foodSearch, toggleTitle, unitActions: unitStore.actions, - units: unitStore.units, + units: unitStore.store, unitSearch, validators, workingUnitData: unitsData.data, diff --git a/frontend/components/Domain/Recipe/RecipeOrganizerDialog.vue b/frontend/components/Domain/Recipe/RecipeOrganizerDialog.vue index 378cda2b061..91f4478868d 100644 --- a/frontend/components/Domain/Recipe/RecipeOrganizerDialog.vue +++ b/frontend/components/Domain/Recipe/RecipeOrganizerDialog.vue @@ -135,7 +135,7 @@ export default defineComponent({ await store.actions.createOne({ ...state }); } - const newItem = store.items.value.find((item) => item.name === state.name); + const newItem = store.store.value.find((item) => item.name === state.name); context.emit(CREATED_ITEM_EVENT, newItem); dialog.value = false; diff --git a/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue b/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue index 16fd636bf24..658f043574b 100644 --- a/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue +++ b/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue @@ -127,9 +127,9 @@ export default defineComponent({ const items = computed(() => { if (!props.returnObject) { - return store.items.value.map((item) => item.name); + return store.store.value.map((item) => item.name); } - return store.items.value; + return store.store.value; }); function removeByIndex(index: number) { diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue index 69853821a79..0fa48f2b0ec 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue @@ -105,7 +105,7 @@ export default defineComponent({ const recipeHousehold = ref<HouseholdSummary>(); if (user) { const userApi = useUserApi(); - userApi.groups.fetchHousehold(props.recipe.householdId).then(({ data }) => { + userApi.households.getOne(props.recipe.householdId).then(({ data }) => { recipeHousehold.value = data || undefined; }); } diff --git a/frontend/components/Domain/SearchFilter.vue b/frontend/components/Domain/SearchFilter.vue index 44e0ef05509..bdacf3aa6e2 100644 --- a/frontend/components/Domain/SearchFilter.vue +++ b/frontend/components/Domain/SearchFilter.vue @@ -11,28 +11,43 @@ <v-card width="400"> <v-card-text> <v-text-field v-model="state.search" class="mb-2" hide-details dense :label="$tc('search.search')" clearable /> - <v-switch - v-if="requireAll != undefined" - v-model="requireAllValue" - dense - small - :label="`${requireAll ? $tc('search.has-all') : $tc('search.has-any')}`" - > - </v-switch> + <div class="d-flex py-4"> + <v-switch + v-if="requireAll != undefined" + v-model="requireAllValue" + dense + small + hide-details + class="my-auto" + :label="`${requireAll ? $tc('search.has-all') : $tc('search.has-any')}`" + /> + <v-spacer /> + <v-btn + small + color="accent" + class="mr-2 my-auto" + @click="clearSelection" + > + {{ $tc("search.clear-selection") }} + </v-btn> + </div> <v-card v-if="filtered.length > 0" flat outlined> + <v-radio-group v-model="selectedRadio" class="ma-0 pa-0"> <v-virtual-scroll :items="filtered" height="300" item-height="51"> <template #default="{ item }"> - <v-list-item :key="item.id" dense :value="item"> - <v-list-item-action> - <v-checkbox v-model="selected" :value="item"></v-checkbox> - </v-list-item-action> - <v-list-item-content> - <v-list-item-title> {{ item.name }}</v-list-item-title> - </v-list-item-content> - </v-list-item> + <v-list-item :key="item.id" dense :value="item"> + <v-list-item-action> + <v-radio v-if="radio" :value="item" @click="handleRadioClick(item)" /> + <v-checkbox v-else v-model="selected" :value="item" /> + </v-list-item-action> + <v-list-item-content> + <v-list-item-title> {{ item.name }} </v-list-item-title> + </v-list-item-content> + </v-list-item> <v-divider></v-divider> </template> </v-virtual-scroll> + </v-radio-group> </v-card> <div v-else> <v-alert type="info" text> {{ $tc('search.no-results') }} </v-alert> @@ -65,6 +80,10 @@ export default defineComponent({ type: Boolean, default: undefined, }, + radio: { + type: Boolean, + default: false, + }, }, setup(props, context) { const state = reactive({ @@ -86,6 +105,13 @@ export default defineComponent({ }, }); + const selectedRadio = computed({ + get: () => (selected.value.length > 0 ? selected.value[0] : null), + set: (value) => { + context.emit("input", value ? [value] : []); + }, + }); + const filtered = computed(() => { if (!state.search) { return props.items; @@ -94,11 +120,26 @@ export default defineComponent({ return props.items.filter((item) => item.name.toLowerCase().includes(state.search.toLowerCase())); }); + const handleRadioClick = (item: SelectableItem) => { + if (selectedRadio.value === item) { + selectedRadio.value = null; + } + }; + + function clearSelection() { + selected.value = []; + selectedRadio.value = null; + state.search = ""; + } + return { requireAllValue, state, selected, + selectedRadio, filtered, + handleRadioClick, + clearSelection, }; }, }); diff --git a/frontend/components/global/CrudTable.vue b/frontend/components/global/CrudTable.vue index caf25a803a2..1c7bfe3c9c2 100644 --- a/frontend/components/global/CrudTable.vue +++ b/frontend/components/global/CrudTable.vue @@ -44,6 +44,8 @@ item-key="id" :show-select="bulkActions.length > 0" :headers="activeHeaders" + :sort-by="initialSort" + :sort-desc="initialSortDesc" :items="data || []" :items-per-page="15" :search="search" @@ -126,6 +128,14 @@ export default defineComponent({ type: Array as () => BulkAction[], default: () => [], }, + initialSort: { + type: String, + default: "id", + }, + initialSortDesc: { + type: Boolean, + default: false, + }, }, setup(props, context) { // =========================================================== diff --git a/frontend/composables/api/index.ts b/frontend/composables/api/index.ts index 3f9056368b8..20d74981d77 100644 --- a/frontend/composables/api/index.ts +++ b/frontend/composables/api/index.ts @@ -1,3 +1,3 @@ export { useAppInfo } from "./use-app-info"; export { useStaticRoutes } from "./static-routes"; -export { useAdminApi, useUserApi } from "./api-client"; +export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client"; diff --git a/frontend/composables/partials/types.ts b/frontend/composables/partials/types.ts new file mode 100644 index 00000000000..1be933e3e77 --- /dev/null +++ b/frontend/composables/partials/types.ts @@ -0,0 +1,3 @@ +export type BoundT = { + id?: string | number | null; +}; diff --git a/frontend/composables/partials/use-actions-factory.ts b/frontend/composables/partials/use-actions-factory.ts index f0b829c78ba..b17b9ac6a3b 100644 --- a/frontend/composables/partials/use-actions-factory.ts +++ b/frontend/composables/partials/use-actions-factory.ts @@ -1,18 +1,15 @@ import { Ref, useAsync } from "@nuxtjs/composition-api"; import { useAsyncKey } from "../use-utils"; +import { BoundT } from "./types"; import { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; import { QueryValue } from "~/lib/api/base/route"; -type BoundT = { - id?: string | number | null; -}; - -interface PublicStoreActions<T extends BoundT> { +interface ReadOnlyStoreActions<T extends BoundT> { getAll(page?: number, perPage?: number, params?: any): Ref<T[] | null>; refresh(): Promise<void>; } -interface StoreActions<T extends BoundT> extends PublicStoreActions<T> { +interface StoreActions<T extends BoundT> extends ReadOnlyStoreActions<T> { createOne(createData: T): Promise<T | null>; updateOne(updateData: T): Promise<T | null>; deleteOne(id: string | number): Promise<T | null>; @@ -20,16 +17,16 @@ interface StoreActions<T extends BoundT> extends PublicStoreActions<T> { /** - * usePublicStoreActions is a factory function that returns a set of methods + * useReadOnlyActions is a factory function that returns a set of methods * that can be reused to manage the state of a data store without using * Vuex. This is primarily used for basic GET/GETALL operations that required * a lot of refreshing hooks to be called on operations */ -export function usePublicStoreActions<T extends BoundT>( +export function useReadOnlyActions<T extends BoundT>( api: BaseCRUDAPIReadOnly<T>, allRef: Ref<T[] | null> | null, loading: Ref<boolean> -): PublicStoreActions<T> { +): ReadOnlyStoreActions<T> { function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) { params.orderBy ??= "name"; params.orderDirection ??= "asc"; diff --git a/frontend/composables/partials/use-store-factory.ts b/frontend/composables/partials/use-store-factory.ts new file mode 100644 index 00000000000..f4f80e14588 --- /dev/null +++ b/frontend/composables/partials/use-store-factory.ts @@ -0,0 +1,53 @@ +import { ref, reactive, Ref } from "@nuxtjs/composition-api"; +import { useReadOnlyActions, useStoreActions } from "./use-actions-factory"; +import { BoundT } from "./types"; +import { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; + +export const useData = function<T extends BoundT>(defaultObject: T) { + const data = reactive({ ...defaultObject }); + function reset() { + Object.assign(data, defaultObject); + }; + + return { data, reset }; +} + +export const useReadOnlyStore = function<T extends BoundT>( + store: Ref<T[]>, + loading: Ref<boolean>, + api: BaseCRUDAPIReadOnly<T>, +) { + const actions = { + ...useReadOnlyActions(api, store, loading), + flushStore() { + store.value = []; + }, + }; + + if (!loading.value && (!store.value || store.value.length === 0)) { + const result = actions.getAll(); + store.value = result.value || []; + } + + return { store, actions }; +} + +export const useStore = function<T extends BoundT>( + store: Ref<T[]>, + loading: Ref<boolean>, + api: BaseCRUDAPI<unknown, T, unknown>, +) { + const actions = { + ...useStoreActions(api, store, loading), + flushStore() { + store = ref([]); + }, + }; + + if (!loading.value && (!store.value || store.value.length === 0)) { + const result = actions.getAll(); + store.value = result.value || []; + } + + return { store, actions }; +} diff --git a/frontend/composables/recipes/use-recipes.ts b/frontend/composables/recipes/use-recipes.ts index 14a6fbcfd1b..c51149a29a8 100644 --- a/frontend/composables/recipes/use-recipes.ts +++ b/frontend/composables/recipes/use-recipes.ts @@ -32,6 +32,7 @@ export const useLazyRecipes = function (publicGroupSlug: string | null = null) { searchSeed: query?._searchSeed, // unused, but pass it along for completeness of data search: query?.search, cookbook: query?.cookbook, + households: query?.households, categories: query?.categories, requireAllCategories: query?.requireAllCategories, tags: query?.tags, diff --git a/frontend/composables/store/index.ts b/frontend/composables/store/index.ts index e00aba57e1a..9dad0b7f161 100644 --- a/frontend/composables/store/index.ts +++ b/frontend/composables/store/index.ts @@ -1,6 +1,7 @@ -export { useFoodStore, useFoodData } from "./use-food-store"; -export { useUnitStore, useUnitData } from "./use-unit-store"; +export { useCategoryStore, usePublicCategoryStore, useCategoryData } from "./use-category-store"; +export { useFoodStore, usePublicFoodStore, useFoodData } from "./use-food-store"; +export { useHouseholdStore, usePublicHouseholdStore } from "./use-household-store"; export { useLabelStore, useLabelData } from "./use-label-store"; -export { useToolStore, useToolData } from "./use-tool-store"; -export { useCategoryStore, useCategoryData } from "./use-category-store"; -export { useTagStore, useTagData } from "./use-tag-store"; +export { useTagStore, usePublicTagStore, useTagData } from "./use-tag-store"; +export { useToolStore, usePublicToolStore, useToolData } from "./use-tool-store"; +export { useUnitStore, useUnitData } from "./use-unit-store"; diff --git a/frontend/composables/store/use-category-store.ts b/frontend/composables/store/use-category-store.ts index 4801bc9ab4b..e64cd060a59 100644 --- a/frontend/composables/store/use-category-store.ts +++ b/frontend/composables/store/use-category-store.ts @@ -1,73 +1,26 @@ -import { reactive, ref, Ref } from "@nuxtjs/composition-api"; -import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory"; -import { usePublicExploreApi } from "../api/api-client"; -import { useUserApi } from "~/composables/api"; +import { ref, Ref } from "@nuxtjs/composition-api"; +import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory"; import { RecipeCategory } from "~/lib/api/types/recipe"; +import { usePublicExploreApi, useUserApi } from "~/composables/api"; -const categoryStore: Ref<RecipeCategory[]> = ref([]); -const publicStoreLoading = ref(false); -const storeLoading = ref(false); +const store: Ref<RecipeCategory[]> = ref([]); +const loading = ref(false); +const publicLoading = ref(false); -export function useCategoryData() { - const data = reactive({ +export const useCategoryData = function () { + return useData<RecipeCategory>({ id: "", name: "", - slug: undefined, + slug: "", }); - - function reset() { - data.id = ""; - data.name = ""; - data.slug = undefined; - } - - return { - data, - reset, - }; -} - -export function usePublicCategoryStore(groupSlug: string) { - const api = usePublicExploreApi(groupSlug).explore; - const loading = publicStoreLoading; - - const actions = { - ...usePublicStoreActions<RecipeCategory>(api.categories, categoryStore, loading), - flushStore() { - categoryStore.value = []; - }, - }; - - if (!loading.value && (!categoryStore.value || categoryStore.value?.length === 0)) { - actions.getAll(); - } - - return { - items: categoryStore, - actions, - loading, - }; } -export function useCategoryStore() { - // passing the group slug switches to using the public API +export const useCategoryStore = function () { const api = useUserApi(); - const loading = storeLoading; - - const actions = { - ...useStoreActions<RecipeCategory>(api.categories, categoryStore, loading), - flushStore() { - categoryStore.value = []; - }, - }; - - if (!loading.value && (!categoryStore.value || categoryStore.value?.length === 0)) { - actions.getAll(); - } + return useStore<RecipeCategory>(store, loading, api.categories); +} - return { - items: categoryStore, - actions, - loading, - }; +export const usePublicCategoryStore = function (groupSlug: string) { + const api = usePublicExploreApi(groupSlug).explore; + return useReadOnlyStore<RecipeCategory>(store, publicLoading, api.categories); } diff --git a/frontend/composables/store/use-food-store.ts b/frontend/composables/store/use-food-store.ts index 4b02210c382..f377763fea3 100644 --- a/frontend/composables/store/use-food-store.ts +++ b/frontend/composables/store/use-food-store.ts @@ -1,73 +1,28 @@ -import { ref, reactive, Ref } from "@nuxtjs/composition-api"; -import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory"; -import { usePublicExploreApi } from "../api/api-client"; -import { useUserApi } from "~/composables/api"; +import { ref, Ref } from "@nuxtjs/composition-api"; +import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory"; import { IngredientFood } from "~/lib/api/types/recipe"; +import { usePublicExploreApi, useUserApi } from "~/composables/api"; -let foodStore: Ref<IngredientFood[] | null> = ref([]); -const publicStoreLoading = ref(false); -const storeLoading = ref(false); +const store: Ref<IngredientFood[]> = ref([]); +const loading = ref(false); +const publicLoading = ref(false); -/** - * useFoodData returns a template reactive object - * for managing the creation of foods. It also provides a - * function to reset the data back to the initial state. - */ export const useFoodData = function () { - const data: IngredientFood = reactive({ + return useData<IngredientFood>({ id: "", name: "", description: "", labelId: undefined, onHand: false, }); - - function reset() { - data.id = ""; - data.name = ""; - data.description = ""; - data.labelId = undefined; - data.onHand = false; - } - - return { - data, - reset, - }; -}; - -export const usePublicFoodStore = function (groupSlug: string) { - const api = usePublicExploreApi(groupSlug).explore; - const loading = publicStoreLoading; - - const actions = { - ...usePublicStoreActions(api.foods, foodStore, loading), - flushStore() { - foodStore = ref([]); - }, - }; - - if (!loading.value && (!foodStore.value || foodStore.value.length === 0)) { - foodStore = actions.getAll(); - } - - return { foods: foodStore, actions }; -}; +} export const useFoodStore = function () { const api = useUserApi(); - const loading = storeLoading; - - const actions = { - ...useStoreActions(api.foods, foodStore, loading), - flushStore() { - foodStore.value = []; - }, - }; + return useStore<IngredientFood>(store, loading, api.foods); +} - if (!loading.value && (!foodStore.value || foodStore.value.length === 0)) { - foodStore = actions.getAll(); - } - - return { foods: foodStore, actions }; -}; +export const usePublicFoodStore = function (groupSlug: string) { + const api = usePublicExploreApi(groupSlug).explore; + return useReadOnlyStore<IngredientFood>(store, publicLoading, api.foods); +} diff --git a/frontend/composables/store/use-household-store.ts b/frontend/composables/store/use-household-store.ts new file mode 100644 index 00000000000..0b7c8eef102 --- /dev/null +++ b/frontend/composables/store/use-household-store.ts @@ -0,0 +1,18 @@ +import { ref, Ref } from "@nuxtjs/composition-api"; +import { useReadOnlyStore } from "../partials/use-store-factory"; +import { HouseholdSummary } from "~/lib/api/types/household"; +import { usePublicExploreApi, useUserApi } from "~/composables/api"; + +const store: Ref<HouseholdSummary[]> = ref([]); +const loading = ref(false); +const publicLoading = ref(false); + +export const useHouseholdStore = function () { + const api = useUserApi(); + return useReadOnlyStore<HouseholdSummary>(store, loading, api.households); +} + +export const usePublicHouseholdStore = function (groupSlug: string) { + const api = usePublicExploreApi(groupSlug).explore; + return useReadOnlyStore<HouseholdSummary>(store, publicLoading, api.households); +} diff --git a/frontend/composables/store/use-label-store.ts b/frontend/composables/store/use-label-store.ts index 72654d3b63a..0cd3bb58d9d 100644 --- a/frontend/composables/store/use-label-store.ts +++ b/frontend/composables/store/use-label-store.ts @@ -1,50 +1,21 @@ -import { reactive, ref, Ref } from "@nuxtjs/composition-api"; -import { useStoreActions } from "../partials/use-actions-factory"; +import { ref, Ref } from "@nuxtjs/composition-api"; +import { useData, useStore } from "../partials/use-store-factory"; import { MultiPurposeLabelOut } from "~/lib/api/types/labels"; import { useUserApi } from "~/composables/api"; -let labelStore: Ref<MultiPurposeLabelOut[] | null> = ref([]); -const storeLoading = ref(false); +const store: Ref<MultiPurposeLabelOut[]> = ref([]); +const loading = ref(false); -export function useLabelData() { - const data = reactive({ +export const useLabelData = function () { + return useData<MultiPurposeLabelOut>({ groupId: "", id: "", name: "", color: "", }); - - function reset() { - data.groupId = ""; - data.id = ""; - data.name = ""; - data.color = ""; - } - - return { - data, - reset, - }; } -export function useLabelStore() { +export const useLabelStore = function () { const api = useUserApi(); - const loading = storeLoading; - - const actions = { - ...useStoreActions<MultiPurposeLabelOut>(api.multiPurposeLabels, labelStore, loading), - flushStore() { - labelStore.value = []; - }, - }; - - if (!loading.value && (!labelStore.value || labelStore.value?.length === 0)) { - labelStore = actions.getAll(); - } - - return { - labels: labelStore, - actions, - loading, - }; + return useStore<MultiPurposeLabelOut>(store, loading, api.multiPurposeLabels); } diff --git a/frontend/composables/store/use-tag-store.ts b/frontend/composables/store/use-tag-store.ts index 395c8e48758..b5a30822aec 100644 --- a/frontend/composables/store/use-tag-store.ts +++ b/frontend/composables/store/use-tag-store.ts @@ -1,72 +1,26 @@ -import { reactive, ref, Ref } from "@nuxtjs/composition-api"; -import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory"; -import { usePublicExploreApi } from "../api/api-client"; -import { useUserApi } from "~/composables/api"; +import { ref, Ref } from "@nuxtjs/composition-api"; +import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory"; import { RecipeTag } from "~/lib/api/types/recipe"; +import { usePublicExploreApi, useUserApi } from "~/composables/api"; -const items: Ref<RecipeTag[]> = ref([]); -const publicStoreLoading = ref(false); -const storeLoading = ref(false); +const store: Ref<RecipeTag[]> = ref([]); +const loading = ref(false); +const publicLoading = ref(false); -export function useTagData() { - const data = reactive({ +export const useTagData = function () { + return useData<RecipeTag>({ id: "", name: "", - slug: undefined, + slug: "", }); - - function reset() { - data.id = ""; - data.name = ""; - data.slug = undefined; - } - - return { - data, - reset, - }; -} - -export function usePublicTagStore(groupSlug: string) { - const api = usePublicExploreApi(groupSlug).explore; - const loading = publicStoreLoading; - - const actions = { - ...usePublicStoreActions<RecipeTag>(api.tags, items, loading), - flushStore() { - items.value = []; - }, - }; - - if (!loading.value && (!items.value || items.value?.length === 0)) { - actions.getAll(); - } - - return { - items, - actions, - loading, - }; } -export function useTagStore() { +export const useTagStore = function () { const api = useUserApi(); - const loading = storeLoading; - - const actions = { - ...useStoreActions<RecipeTag>(api.tags, items, loading), - flushStore() { - items.value = []; - }, - }; - - if (!loading.value && (!items.value || items.value?.length === 0)) { - actions.getAll(); - } + return useStore<RecipeTag>(store, loading, api.tags); +} - return { - items, - actions, - loading, - }; +export const usePublicTagStore = function (groupSlug: string) { + const api = usePublicExploreApi(groupSlug).explore; + return useReadOnlyStore<RecipeTag>(store, publicLoading, api.tags); } diff --git a/frontend/composables/store/use-tool-store.ts b/frontend/composables/store/use-tool-store.ts index 7b14381e5bb..d27fa20c089 100644 --- a/frontend/composables/store/use-tool-store.ts +++ b/frontend/composables/store/use-tool-store.ts @@ -1,74 +1,27 @@ -import { reactive, ref, Ref } from "@nuxtjs/composition-api"; -import { usePublicExploreApi } from "../api/api-client"; -import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory"; -import { useUserApi } from "~/composables/api"; +import { ref, Ref } from "@nuxtjs/composition-api"; +import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory"; import { RecipeTool } from "~/lib/api/types/recipe"; +import { usePublicExploreApi, useUserApi } from "~/composables/api"; -const toolStore: Ref<RecipeTool[]> = ref([]); -const publicStoreLoading = ref(false); -const storeLoading = ref(false); +const store: Ref<RecipeTool[]> = ref([]); +const loading = ref(false); +const publicLoading = ref(false); -export function useToolData() { - const data = reactive({ +export const useToolData = function () { + return useData<RecipeTool>({ id: "", name: "", - slug: undefined, + slug: "", onHand: false, }); - - function reset() { - data.id = ""; - data.name = ""; - data.slug = undefined; - data.onHand = false; - } - - return { - data, - reset, - }; -} - -export function usePublicToolStore(groupSlug: string) { - const api = usePublicExploreApi(groupSlug).explore; - const loading = publicStoreLoading; - - const actions = { - ...usePublicStoreActions<RecipeTool>(api.tools, toolStore, loading), - flushStore() { - toolStore.value = []; - }, - }; - - if (!loading.value && (!toolStore.value || toolStore.value?.length === 0)) { - actions.getAll(); - } - - return { - items: toolStore, - actions, - loading, - }; } -export function useToolStore() { +export const useToolStore = function () { const api = useUserApi(); - const loading = storeLoading; - - const actions = { - ...useStoreActions<RecipeTool>(api.tools, toolStore, loading), - flushStore() { - toolStore.value = []; - }, - }; - - if (!loading.value && (!toolStore.value || toolStore.value?.length === 0)) { - actions.getAll(); - } + return useStore<RecipeTool>(store, loading, api.tools); +} - return { - items: toolStore, - actions, - loading, - }; +export const usePublicToolStore = function (groupSlug: string) { + const api = usePublicExploreApi(groupSlug).explore; + return useReadOnlyStore<RecipeTool>(store, publicLoading, api.tools); } diff --git a/frontend/composables/store/use-unit-store.ts b/frontend/composables/store/use-unit-store.ts index 527a2ea7707..3bf0926a66b 100644 --- a/frontend/composables/store/use-unit-store.ts +++ b/frontend/composables/store/use-unit-store.ts @@ -1,53 +1,22 @@ -import { ref, reactive, Ref } from "@nuxtjs/composition-api"; -import { useStoreActions } from "../partials/use-actions-factory"; -import { useUserApi } from "~/composables/api"; +import { ref, Ref } from "@nuxtjs/composition-api"; +import { useData, useStore } from "../partials/use-store-factory"; import { IngredientUnit } from "~/lib/api/types/recipe"; +import { useUserApi } from "~/composables/api"; -let unitStore: Ref<IngredientUnit[] | null> = ref([]); -const storeLoading = ref(false); +const store: Ref<IngredientUnit[]> = ref([]); +const loading = ref(false); -/** - * useUnitData returns a template reactive object - * for managing the creation of units. It also provides a - * function to reset the data back to the initial state. - */ export const useUnitData = function () { - const data: IngredientUnit = reactive({ + return useData<IngredientUnit>({ id: "", name: "", fraction: true, abbreviation: "", description: "", }); - - function reset() { - data.id = ""; - data.name = ""; - data.fraction = true; - data.abbreviation = ""; - data.description = ""; - } - - return { - data, - reset, - }; -}; +} export const useUnitStore = function () { const api = useUserApi(); - const loading = storeLoading; - - const actions = { - ...useStoreActions<IngredientUnit>(api.units, unitStore, loading), - flushStore() { - unitStore.value = []; - }, - }; - - if (!loading.value && (!unitStore.value || unitStore.value.length === 0)) { - unitStore = actions.getAll(); - } - - return { units: unitStore, actions }; -}; + return useStore<IngredientUnit>(store, loading, api.units); +} diff --git a/frontend/composables/use-households.ts b/frontend/composables/use-households.ts index 44f9caf8f4e..c22d04422de 100644 --- a/frontend/composables/use-households.ts +++ b/frontend/composables/use-households.ts @@ -1,5 +1,5 @@ import { computed, ref, Ref, useAsync } from "@nuxtjs/composition-api"; -import { useUserApi } from "~/composables/api"; +import { useAdminApi, useUserApi } from "~/composables/api"; import { HouseholdCreate, HouseholdInDB } from "~/lib/api/types/household"; const householdSelfRef = ref<HouseholdInDB | null>(null); @@ -46,8 +46,8 @@ export const useHouseholdSelf = function () { return { actions, household }; }; -export const useHouseholds = function () { - const api = useUserApi(); +export const useAdminHouseholds = function () { + const api = useAdminApi(); const loading = ref(false); function getAllHouseholds() { diff --git a/frontend/composables/use-users/user-ratings.ts b/frontend/composables/use-users/user-ratings.ts index cf29576786b..0f82cd71893 100644 --- a/frontend/composables/use-users/user-ratings.ts +++ b/frontend/composables/use-users/user-ratings.ts @@ -7,34 +7,37 @@ const loading = ref(false); const ready = ref(false); export const useUserSelfRatings = function () { - const { $auth } = useContext(); - const api = useUserApi(); + const { $auth } = useContext(); + const api = useUserApi(); - async function refreshUserRatings() { - if (loading.value) { - return; - } - - loading.value = true; - const { data } = await api.users.getSelfRatings(); - userRatings.value = data?.ratings || []; - loading.value = false; - ready.value = true; + async function refreshUserRatings() { + if (!$auth.user || loading.value) { + return; } - async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) { - loading.value = true; - const userId = $auth.user?.id || ""; - await api.users.setRating(userId, slug, rating, isFavorite); - loading.value = false; - await refreshUserRatings(); - } + loading.value = true; + const { data } = await api.users.getSelfRatings(); + userRatings.value = data?.ratings || []; + loading.value = false; + ready.value = true; + } + + async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) { + loading.value = true; + const userId = $auth.user?.id || ""; + await api.users.setRating(userId, slug, rating, isFavorite); + loading.value = false; + await refreshUserRatings(); + } + if (!ready.value) { refreshUserRatings(); - return { - userRatings, - refreshUserRatings, - setRating, - ready, - } + } + + return { + userRatings, + refreshUserRatings, + setRating, + ready, + } } diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 382aa53aca0..2dd9dcee3c2 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -652,6 +652,7 @@ "or": "Or", "has-any": "Has Any", "has-all": "Has All", + "clear-selection": "Clear Selection", "results": "Results", "search": "Search", "search-mealie": "Search Mealie (press /)", diff --git a/frontend/lib/api/admin/admin-households.ts b/frontend/lib/api/admin/admin-households.ts new file mode 100644 index 00000000000..1e49723b760 --- /dev/null +++ b/frontend/lib/api/admin/admin-households.ts @@ -0,0 +1,13 @@ +import { BaseCRUDAPI } from "../base/base-clients"; +import { HouseholdCreate, HouseholdInDB, UpdateHouseholdAdmin } from "~/lib/api/types/household"; +const prefix = "/api"; + +const routes = { + adminHouseholds: `${prefix}/admin/households`, + adminHouseholdsId: (id: string) => `${prefix}/admin/households/${id}`, +}; + +export class AdminHouseholdsApi extends BaseCRUDAPI<HouseholdCreate, HouseholdInDB, UpdateHouseholdAdmin> { + baseRoute: string = routes.adminHouseholds; + itemRoute = routes.adminHouseholdsId; +} diff --git a/frontend/lib/api/client-admin.ts b/frontend/lib/api/client-admin.ts index a0bbca8f80f..bf151d390ed 100644 --- a/frontend/lib/api/client-admin.ts +++ b/frontend/lib/api/client-admin.ts @@ -1,5 +1,6 @@ import { AdminAboutAPI } from "./admin/admin-about"; import { AdminUsersApi } from "./admin/admin-users"; +import { AdminHouseholdsApi } from "./admin/admin-households"; import { AdminGroupsApi } from "./admin/admin-groups"; import { AdminBackupsApi } from "./admin/admin-backups"; import { AdminMaintenanceApi } from "./admin/admin-maintenance"; @@ -9,6 +10,7 @@ import { ApiRequestInstance } from "~/lib/api/types/non-generated"; export class AdminAPI { public about: AdminAboutAPI; public users: AdminUsersApi; + public households: AdminHouseholdsApi; public groups: AdminGroupsApi; public backups: AdminBackupsApi; public maintenance: AdminMaintenanceApi; @@ -17,6 +19,7 @@ export class AdminAPI { constructor(requests: ApiRequestInstance) { this.about = new AdminAboutAPI(requests); this.users = new AdminUsersApi(requests); + this.households = new AdminHouseholdsApi(requests); this.groups = new AdminGroupsApi(requests); this.backups = new AdminBackupsApi(requests); this.maintenance = new AdminMaintenanceApi(requests); diff --git a/frontend/lib/api/public/explore.ts b/frontend/lib/api/public/explore.ts index 51f7d71357e..7a26a6380b4 100644 --- a/frontend/lib/api/public/explore.ts +++ b/frontend/lib/api/public/explore.ts @@ -4,6 +4,7 @@ import { PublicRecipeApi } from "./explore/recipes"; import { PublicFoodsApi } from "./explore/foods"; import { PublicCategoriesApi, PublicTagsApi, PublicToolsApi } from "./explore/organizers"; import { PublicCookbooksApi } from "./explore/cookbooks"; +import { PublicHouseholdApi } from "./explore/households"; export class ExploreApi extends BaseAPI { public recipes: PublicRecipeApi; @@ -12,6 +13,7 @@ export class ExploreApi extends BaseAPI { public categories: PublicCategoriesApi; public tags: PublicTagsApi; public tools: PublicToolsApi; + public households: PublicHouseholdApi constructor(requests: ApiRequestInstance, groupSlug: string) { super(requests); @@ -21,5 +23,6 @@ export class ExploreApi extends BaseAPI { this.categories = new PublicCategoriesApi(requests, groupSlug); this.tags = new PublicTagsApi(requests, groupSlug); this.tools = new PublicToolsApi(requests, groupSlug); + this.households = new PublicHouseholdApi(requests, groupSlug); } } diff --git a/frontend/lib/api/public/explore/households.ts b/frontend/lib/api/public/explore/households.ts new file mode 100644 index 00000000000..188d75053c4 --- /dev/null +++ b/frontend/lib/api/public/explore/households.ts @@ -0,0 +1,20 @@ +import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; +import { HouseholdSummary } from "~/lib/api/types/household"; +import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated"; + +const prefix = "/api"; +const exploreGroupSlug = (groupSlug: string | number) => `${prefix}/explore/groups/${groupSlug}` + +const routes = { + householdsGroupSlug: (groupSlug: string | number) => `${exploreGroupSlug(groupSlug)}/households`, + householdsGroupSlugHouseholdSlug: (groupSlug: string | number, householdSlug: string | number) => `${exploreGroupSlug(groupSlug)}/households/${householdSlug}`, +}; + +export class PublicHouseholdApi extends BaseCRUDAPIReadOnly<HouseholdSummary> { + baseRoute = routes.householdsGroupSlug(this.groupSlug); + itemRoute = (itemId: string | number) => routes.householdsGroupSlugHouseholdSlug(this.groupSlug, itemId); + + constructor(requests: ApiRequestInstance, private readonly groupSlug: string) { + super(requests); + } +} diff --git a/frontend/lib/api/user/groups.ts b/frontend/lib/api/user/groups.ts index 5dc85110abb..fcd0a70d093 100644 --- a/frontend/lib/api/user/groups.ts +++ b/frontend/lib/api/user/groups.ts @@ -1,6 +1,5 @@ import { BaseCRUDAPI } from "../base/base-clients"; import { GroupBase, GroupInDB, GroupSummary, UserSummary } from "~/lib/api/types/user"; -import { HouseholdSummary } from "~/lib/api/types/household"; import { GroupAdminUpdate, GroupStorage, @@ -15,8 +14,6 @@ const routes = { groupsSelf: `${prefix}/groups/self`, preferences: `${prefix}/groups/preferences`, storage: `${prefix}/groups/storage`, - households: `${prefix}/groups/households`, - householdsId: (id: string | number) => `${prefix}/groups/households/${id}`, membersHouseholdId: (householdId: string | number | null) => { return householdId ? `${prefix}/households/members?householdId=${householdId}` : @@ -47,14 +44,6 @@ export class GroupAPI extends BaseCRUDAPI<GroupBase, GroupInDB, GroupAdminUpdate return await this.requests.get<UserSummary[]>(routes.membersHouseholdId(householdId)); } - async fetchHouseholds() { - return await this.requests.get<HouseholdSummary[]>(routes.households); - } - - async fetchHousehold(householdId: string | number) { - return await this.requests.get<HouseholdSummary>(routes.householdsId(householdId)); - } - async storage() { return await this.requests.get<GroupStorage>(routes.storage); } diff --git a/frontend/lib/api/user/households.ts b/frontend/lib/api/user/households.ts index b1909e78b08..22fcad7d07f 100644 --- a/frontend/lib/api/user/households.ts +++ b/frontend/lib/api/user/households.ts @@ -1,21 +1,20 @@ -import { BaseCRUDAPI } from "../base/base-clients"; +import { BaseCRUDAPIReadOnly } from "../base/base-clients"; import { UserOut } from "~/lib/api/types/user"; import { - HouseholdCreate, HouseholdInDB, - UpdateHouseholdAdmin, HouseholdStatistics, ReadHouseholdPreferences, SetPermissions, UpdateHouseholdPreferences, CreateInviteToken, ReadInviteToken, + HouseholdSummary, } from "~/lib/api/types/household"; const prefix = "/api"; const routes = { - households: `${prefix}/admin/households`, + households: `${prefix}/groups/households`, householdsSelf: `${prefix}/households/self`, members: `${prefix}/households/members`, permissions: `${prefix}/households/permissions`, @@ -24,13 +23,13 @@ const routes = { statistics: `${prefix}/households/statistics`, invitation: `${prefix}/households/invitations`, - householdsId: (id: string | number) => `${prefix}/admin/households/${id}`, + householdsId: (id: string | number) => `${prefix}/groups/households/${id}`, }; -export class HouseholdAPI extends BaseCRUDAPI<HouseholdCreate, HouseholdInDB, UpdateHouseholdAdmin> { +export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> { baseRoute = routes.households; itemRoute = routes.householdsId; - /** Returns the Group Data for the Current User + /** Returns the Household Data for the Current User */ async getCurrentUserHousehold() { return await this.requests.get<HouseholdInDB>(routes.householdsSelf); diff --git a/frontend/lib/api/user/recipes/recipe.ts b/frontend/lib/api/user/recipes/recipe.ts index 8aaeb317295..8e3b673b6de 100644 --- a/frontend/lib/api/user/recipes/recipe.ts +++ b/frontend/lib/api/user/recipes/recipe.ts @@ -56,13 +56,14 @@ const routes = { }; export type RecipeSearchQuery = { - search: string; + search?: string; orderDirection?: "asc" | "desc"; groupId?: string; queryFilter?: string; cookbook?: string; + households?: string[]; categories?: string[]; requireAllCategories?: boolean; diff --git a/frontend/pages/admin/manage/households/_id.vue b/frontend/pages/admin/manage/households/_id.vue index 3e897cff2b1..f1fac388e4f 100644 --- a/frontend/pages/admin/manage/households/_id.vue +++ b/frontend/pages/admin/manage/households/_id.vue @@ -45,7 +45,7 @@ import { defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api"; import HouseholdPreferencesEditor from "~/components/Domain/Household/HouseholdPreferencesEditor.vue"; import { useGroups } from "~/composables/use-groups"; -import { useUserApi } from "~/composables/api"; +import { useAdminApi } from "~/composables/api"; import { alert } from "~/composables/use-toast"; import { validators } from "~/composables/use-validators"; import { HouseholdInDB } from "~/lib/api/types/household"; @@ -68,14 +68,14 @@ export default defineComponent({ const refHouseholdEditForm = ref<VForm | null>(null); - const userApi = useUserApi(); + const adminApi = useAdminApi(); const household = ref<HouseholdInDB | null>(null); const userError = ref(false); onMounted(async () => { - const { data, error } = await userApi.households.getOne(householdId); + const { data, error } = await adminApi.households.getOne(householdId); if (error?.response?.status === 404) { alert.error(i18n.tc("user.user-not-found")); @@ -92,7 +92,7 @@ export default defineComponent({ return; } - const { response, data } = await userApi.households.updateOne(household.value.id, household.value); + const { response, data } = await adminApi.households.updateOne(household.value.id, household.value); if (response?.status === 200 && data) { household.value = data; alert.success(i18n.tc("settings.settings-updated")); diff --git a/frontend/pages/admin/manage/households/index.vue b/frontend/pages/admin/manage/households/index.vue index 02c36914c80..122ba20b192 100644 --- a/frontend/pages/admin/manage/households/index.vue +++ b/frontend/pages/admin/manage/households/index.vue @@ -88,7 +88,7 @@ import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api"; import { fieldTypes } from "~/composables/forms"; import { useGroups } from "~/composables/use-groups"; -import { useHouseholds } from "~/composables/use-households"; +import { useAdminHouseholds } from "~/composables/use-households"; import { validators } from "~/composables/use-validators"; import { HouseholdInDB } from "~/lib/api/types/household"; @@ -97,7 +97,7 @@ export default defineComponent({ setup() { const { i18n } = useContext(); const { groups } = useGroups(); - const { households, refreshAllHouseholds, deleteHousehold, createHousehold } = useHouseholds(); + const { households, refreshAllHouseholds, deleteHousehold, createHousehold } = useAdminHouseholds(); const state = reactive({ createDialog: false, diff --git a/frontend/pages/admin/manage/users/_id.vue b/frontend/pages/admin/manage/users/_id.vue index 6982658a668..eac8b5bf9a6 100644 --- a/frontend/pages/admin/manage/users/_id.vue +++ b/frontend/pages/admin/manage/users/_id.vue @@ -80,7 +80,7 @@ import { computed, defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api"; import { useAdminApi, useUserApi } from "~/composables/api"; import { useGroups } from "~/composables/use-groups"; -import { useHouseholds } from "~/composables/use-households"; +import { useAdminHouseholds } from "~/composables/use-households"; import { alert } from "~/composables/use-toast"; import { useUserForm } from "~/composables/use-users"; import { validators } from "~/composables/use-validators"; @@ -92,7 +92,7 @@ export default defineComponent({ setup() { const { userForm } = useUserForm(); const { groups } = useGroups(); - const { useHouseholdsInGroup } = useHouseholds(); + const { useHouseholdsInGroup } = useAdminHouseholds(); const { i18n } = useContext(); const route = useRoute(); diff --git a/frontend/pages/admin/manage/users/create.vue b/frontend/pages/admin/manage/users/create.vue index 0d0e380e239..2dc0cc19af1 100644 --- a/frontend/pages/admin/manage/users/create.vue +++ b/frontend/pages/admin/manage/users/create.vue @@ -50,7 +50,7 @@ import { computed, defineComponent, useRouter, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api"; import { useAdminApi } from "~/composables/api"; import { useGroups } from "~/composables/use-groups"; -import { useHouseholds } from "~/composables/use-households"; +import { useAdminHouseholds } from "~/composables/use-households"; import { useUserForm } from "~/composables/use-users"; import { validators } from "~/composables/use-validators"; import { VForm } from "~/types/vuetify"; @@ -60,7 +60,7 @@ export default defineComponent({ setup() { const { userForm } = useUserForm(); const { groups } = useGroups(); - const { useHouseholdsInGroup } = useHouseholds(); + const { useHouseholdsInGroup } = useAdminHouseholds(); const router = useRouter(); // ============================================== diff --git a/frontend/pages/admin/setup.vue b/frontend/pages/admin/setup.vue index b244dcdf13e..ef8e25c2a10 100644 --- a/frontend/pages/admin/setup.vue +++ b/frontend/pages/admin/setup.vue @@ -94,7 +94,7 @@ <script lang="ts"> import { computed, defineComponent, ref, useContext, useRouter } from "@nuxtjs/composition-api"; -import { useUserApi } from "~/composables/api"; +import { useAdminApi, useUserApi } from "~/composables/api"; import { useLocales } from "~/composables/use-locales"; import { alert } from "~/composables/use-toast"; import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form"; @@ -108,7 +108,8 @@ export default defineComponent({ // ================================================================ // Setup const { $auth, $globals, i18n } = useContext(); - const api = useUserApi(); + const userApi = useUserApi(); + const adminApi = useAdminApi(); const groupSlug = computed(() => $auth.user?.groupSlug); const { locale } = useLocales(); @@ -264,7 +265,7 @@ export default defineComponent({ async function updateUser() { // @ts-ignore-next-line user will never be null here - const { response } = await api.users.updateOne($auth.user?.id, { + const { response } = await userApi.users.updateOne($auth.user?.id, { ...$auth.user, email: accountDetails.email.value, username: accountDetails.username.value, @@ -285,7 +286,7 @@ export default defineComponent({ } async function updatePassword() { - const { response } = await api.users.changePassword({ + const { response } = await userApi.users.changePassword({ currentPassword: "MyPassword", newPassword: credentials.password1.value, }); @@ -303,7 +304,7 @@ export default defineComponent({ async function updateGroup() { // @ts-ignore-next-line user will never be null here - const { data } = await api.groups.getOne($auth.user?.groupId); + const { data } = await userApi.groups.getOne($auth.user?.groupId); if (!data || !data.preferences) { alert.error(i18n.tc("events.something-went-wrong")); return; @@ -320,7 +321,7 @@ export default defineComponent({ } // @ts-ignore-next-line user will never be null here - const { response } = await api.groups.updateOne($auth.user?.groupId, payload); + const { response } = await userApi.groups.updateOne($auth.user?.groupId, payload); if (!response || response.status !== 200) { alert.error(i18n.tc("events.something-went-wrong")); } @@ -328,7 +329,7 @@ export default defineComponent({ async function updateHousehold() { // @ts-ignore-next-line user will never be null here - const { data } = await api.households.getOne($auth.user?.householdId); + const { data } = await adminApi.households.getOne($auth.user?.householdId); if (!data || !data.preferences) { alert.error(i18n.tc("events.something-went-wrong")); return; @@ -346,28 +347,28 @@ export default defineComponent({ } // @ts-ignore-next-line user will never be null here - const { response } = await api.households.updateOne($auth.user?.householdId, payload); + const { response } = await adminApi.households.updateOne($auth.user?.householdId, payload); if (!response || response.status !== 200) { alert.error(i18n.tc("events.something-went-wrong")); } } async function seedFoods() { - const { response } = await api.seeders.foods({ locale: locale.value }) + const { response } = await userApi.seeders.foods({ locale: locale.value }) if (!response || response.status !== 200) { alert.error(i18n.tc("events.something-went-wrong")); } } async function seedUnits() { - const { response } = await api.seeders.units({ locale: locale.value }) + const { response } = await userApi.seeders.units({ locale: locale.value }) if (!response || response.status !== 200) { alert.error(i18n.tc("events.something-went-wrong")); } } async function seedLabels() { - const { response } = await api.seeders.labels({ locale: locale.value }) + const { response } = await userApi.seeders.labels({ locale: locale.value }) if (!response || response.status !== 200) { alert.error(i18n.tc("events.something-went-wrong")); } diff --git a/frontend/pages/g/_groupSlug/r/_slug/ingredient-parser.vue b/frontend/pages/g/_groupSlug/r/_slug/ingredient-parser.vue index 357be89c18a..9aa4518e821 100644 --- a/frontend/pages/g/_groupSlug/r/_slug/ingredient-parser.vue +++ b/frontend/pages/g/_groupSlug/r/_slug/ingredient-parser.vue @@ -272,12 +272,10 @@ export default defineComponent({ const errors = ref<Error[]>([]); function checkForUnit(unit?: IngredientUnit | CreateIngredientUnit) { - // @ts-expect-error; we're just checking if there's an id on this unit and returning a boolean return !!unit?.id; } function checkForFood(food?: IngredientFood | CreateIngredientFood) { - // @ts-expect-error; we're just checking if there's an id on this food and returning a boolean return !!food?.id; } diff --git a/frontend/pages/g/_groupSlug/recipes/categories/index.vue b/frontend/pages/g/_groupSlug/recipes/categories/index.vue index 1cc94cf8681..452f672d8b7 100644 --- a/frontend/pages/g/_groupSlug/recipes/categories/index.vue +++ b/frontend/pages/g/_groupSlug/recipes/categories/index.vue @@ -1,8 +1,8 @@ <template> <v-container> <RecipeOrganizerPage - v-if="items" - :items="items" + v-if="store" + :items="store" :icon="$globals.icons.categories" item-type="categories" @delete="actions.deleteOne" @@ -24,10 +24,10 @@ export default defineComponent({ }, middleware: ["auth", "group-only"], setup() { - const { items, actions } = useCategoryStore(); + const { store, actions } = useCategoryStore(); return { - items, + store, actions, }; }, diff --git a/frontend/pages/g/_groupSlug/recipes/tags/index.vue b/frontend/pages/g/_groupSlug/recipes/tags/index.vue index 861fead9fac..ddd1ff02db7 100644 --- a/frontend/pages/g/_groupSlug/recipes/tags/index.vue +++ b/frontend/pages/g/_groupSlug/recipes/tags/index.vue @@ -1,8 +1,8 @@ <template> <v-container> <RecipeOrganizerPage - v-if="items" - :items="items" + v-if="store" + :items="store" :icon="$globals.icons.tags" item-type="tags" @delete="actions.deleteOne" @@ -24,10 +24,10 @@ export default defineComponent({ }, middleware: ["auth", "group-only"], setup() { - const { items, actions } = useTagStore(); + const { store, actions } = useTagStore(); return { - items, + store, actions, }; }, diff --git a/frontend/pages/g/_groupSlug/recipes/tools/index.vue b/frontend/pages/g/_groupSlug/recipes/tools/index.vue index ff2eaa8b205..5ec786051f5 100644 --- a/frontend/pages/g/_groupSlug/recipes/tools/index.vue +++ b/frontend/pages/g/_groupSlug/recipes/tools/index.vue @@ -29,7 +29,7 @@ export default defineComponent({ return { dialog, - tools: toolStore.items, + tools: toolStore.store, actions: toolStore.actions, }; }, diff --git a/frontend/pages/group/data/categories.vue b/frontend/pages/group/data/categories.vue index 22ede431fae..271ec53bacb 100644 --- a/frontend/pages/group/data/categories.vue +++ b/frontend/pages/group/data/categories.vue @@ -81,6 +81,7 @@ :headers.sync="tableHeaders" :data="categories || []" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" + initial-sort="name" @delete-one="deleteEventHandler" @edit-one="editEventHandler" @delete-selected="bulkDeleteEventHandler" @@ -198,7 +199,7 @@ export default defineComponent({ state, tableConfig, tableHeaders, - categories: categoryStore.items, + categories: categoryStore.store, validators, // create diff --git a/frontend/pages/group/data/foods.vue b/frontend/pages/group/data/foods.vue index 290b7dcef8e..cb41a09bd2d 100644 --- a/frontend/pages/group/data/foods.vue +++ b/frontend/pages/group/data/foods.vue @@ -241,6 +241,8 @@ {icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}, {icon: $globals.icons.tags, text: $tc('data-pages.labels.assign-label'), event: 'assign-selected'} ]" + initial-sort="createdAt" + initial-sort-desc @delete-one="deleteEventHandler" @edit-one="editEventHandler" @create-one="createEventHandler" @@ -264,6 +266,9 @@ {{ item.onHand ? $globals.icons.check : $globals.icons.close }} </v-icon> </template> + <template #item.createdAt="{ item }"> + {{ formatDate(item.createdAt) }} + </template> <template #button-bottom> <BaseButton @click="seedDialog = true"> <template #icon> {{ $globals.icons.database }} </template> @@ -326,8 +331,21 @@ export default defineComponent({ value: "onHand", show: true, }, + { + text: i18n.tc("general.date-added"), + value: "createdAt", + show: false, + } ]; + function formatDate(date: string) { + try { + return i18n.d(Date.parse(date), "medium"); + } catch { + return ""; + } + } + const foodStore = useFoodStore(); // =============================================================== @@ -453,7 +471,7 @@ export default defineComponent({ // ============================================================ // Labels - const { labels: allLabels } = useLabelStore(); + const { store: allLabels } = useLabelStore(); // ============================================================ // Seed @@ -501,16 +519,15 @@ export default defineComponent({ bulkAssignTarget.value = []; bulkAssignLabelId.value = undefined; foodStore.actions.refresh(); - // reload page, because foodStore.actions.refresh() does not update the table, reactivity for this seems to be broken (again) - document.location.reload(); } return { tableConfig, tableHeaders, - foods: foodStore.foods, + foods: foodStore.store, allLabels, validators, + formatDate, // Create createDialog, domNewFoodForm, diff --git a/frontend/pages/group/data/labels.vue b/frontend/pages/group/data/labels.vue index ece501144fe..d30af8c1838 100644 --- a/frontend/pages/group/data/labels.vue +++ b/frontend/pages/group/data/labels.vue @@ -115,6 +115,7 @@ :headers.sync="tableHeaders" :data="labels || []" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" + initial-sort="name" @delete-one="deleteEventHandler" @edit-one="editEventHandler" @delete-selected="bulkDeleteEventHandler" @@ -271,7 +272,7 @@ export default defineComponent({ state, tableConfig, tableHeaders, - labels: labelStore.labels, + labels: labelStore.store, validators, // create diff --git a/frontend/pages/group/data/recipe-actions.vue b/frontend/pages/group/data/recipe-actions.vue index 2b4402dc8a9..6a753c520fc 100644 --- a/frontend/pages/group/data/recipe-actions.vue +++ b/frontend/pages/group/data/recipe-actions.vue @@ -101,6 +101,7 @@ :headers.sync="tableHeaders" :data="actions || []" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" + initial-sort="title" @delete-one="deleteEventHandler" @edit-one="editEventHandler" @delete-selected="bulkDeleteEventHandler" diff --git a/frontend/pages/group/data/tags.vue b/frontend/pages/group/data/tags.vue index 9e33049e801..73cc4ddbcd1 100644 --- a/frontend/pages/group/data/tags.vue +++ b/frontend/pages/group/data/tags.vue @@ -81,6 +81,7 @@ :headers.sync="tableHeaders" :data="tags || []" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" + initial-sort="name" @delete-one="deleteEventHandler" @edit-one="editEventHandler" @delete-selected="bulkDeleteEventHandler" @@ -199,7 +200,7 @@ export default defineComponent({ state, tableConfig, tableHeaders, - tags: tagStore.items, + tags: tagStore.store, validators, // create diff --git a/frontend/pages/group/data/tools.vue b/frontend/pages/group/data/tools.vue index 358c3a52935..ac769c1a2db 100644 --- a/frontend/pages/group/data/tools.vue +++ b/frontend/pages/group/data/tools.vue @@ -83,6 +83,7 @@ :headers.sync="tableHeaders" :data="tools || []" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" + initial-sort="name" @delete-one="deleteEventHandler" @edit-one="editEventHandler" @delete-selected="bulkDeleteEventHandler" @@ -209,7 +210,7 @@ export default defineComponent({ state, tableConfig, tableHeaders, - tools: toolStore.items, + tools: toolStore.store, validators, // create diff --git a/frontend/pages/group/data/units.vue b/frontend/pages/group/data/units.vue index 024a229fbdf..b5c6d5b3857 100644 --- a/frontend/pages/group/data/units.vue +++ b/frontend/pages/group/data/units.vue @@ -9,11 +9,11 @@ </template> </i18n> - <v-autocomplete v-model="fromUnit" return-object :items="units" item-text="id" :label="$t('data-pages.units.source-unit')"> + <v-autocomplete v-model="fromUnit" return-object :items="store" item-text="id" :label="$t('data-pages.units.source-unit')"> <template #selection="{ item }"> {{ item.name }}</template> <template #item="{ item }"> {{ item.name }} </template> </v-autocomplete> - <v-autocomplete v-model="toUnit" return-object :items="units" item-text="id" :label="$t('data-pages.units.target-unit')"> + <v-autocomplete v-model="toUnit" return-object :items="store" item-text="id" :label="$t('data-pages.units.target-unit')"> <template #selection="{ item }"> {{ item.name }}</template> <template #item="{ item }"> {{ item.name }} </template> </v-autocomplete> @@ -185,7 +185,7 @@ </template> </v-autocomplete> - <v-alert v-if="units && units.length > 0" type="error" class="mb-0 text-body-2"> + <v-alert v-if="store && store.length > 0" type="error" class="mb-0 text-body-2"> {{ $t("data-pages.foods.seed-dialog-warning") }} </v-alert> </v-card-text> @@ -196,8 +196,10 @@ <CrudTable :table-config="tableConfig" :headers.sync="tableHeaders" - :data="units || []" + :data="store" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" + initial-sort="createdAt" + initial-sort-desc @delete-one="deleteEventHandler" @edit-one="editEventHandler" @create-one="createEventHandler" @@ -221,6 +223,9 @@ {{ item.fraction ? $globals.icons.check : $globals.icons.close }} </v-icon> </template> + <template #item.createdAt="{ item }"> + {{ formatDate(item.createdAt) }} + </template> <template #button-bottom> <BaseButton @click="seedDialog = true"> <template #icon> {{ $globals.icons.database }} </template> @@ -292,9 +297,22 @@ export default defineComponent({ value: "fraction", show: true, }, + { + text: i18n.tc("general.date-added"), + value: "createdAt", + show: false, + }, ]; - const { units, actions: unitActions } = useUnitStore(); + function formatDate(date: string) { + try { + return i18n.d(Date.parse(date), "medium"); + } catch { + return ""; + } + } + + const { store, actions: unitActions } = useUnitStore(); // ============================================================ // Create Units @@ -447,8 +465,9 @@ export default defineComponent({ return { tableConfig, tableHeaders, - units, + store, validators, + formatDate, // Create createDialog, domNewUnitForm, diff --git a/frontend/pages/shopping-lists/_id.vue b/frontend/pages/shopping-lists/_id.vue index 09ba649fa3d..54c4a471b8f 100644 --- a/frontend/pages/shopping-lists/_id.vue +++ b/frontend/pages/shopping-lists/_id.vue @@ -602,9 +602,9 @@ export default defineComponent({ const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>() - const { labels: allLabels } = useLabelStore(); - const { units: allUnits } = useUnitStore(); - const { foods: allFoods } = useFoodStore(); + const { store: allLabels } = useLabelStore(); + const { store: allUnits } = useUnitStore(); + const { store: allFoods } = useFoodStore(); function getLabelColor(item: ShoppingListItemOut | null) { return item?.label?.color; diff --git a/frontend/pages/user/_id/favorites.vue b/frontend/pages/user/_id/favorites.vue index e7114a2b9c2..6f5e9dcc66c 100644 --- a/frontend/pages/user/_id/favorites.vue +++ b/frontend/pages/user/_id/favorites.vue @@ -5,34 +5,40 @@ :icon="$globals.icons.heart" :title="$tc('user.user-favorites')" :recipes="recipes" + :query="query" + @sortRecipes="assignSorted" + @replaceRecipes="replaceRecipes" + @appendRecipes="appendRecipes" + @delete="removeRecipe" /> </v-container> </template> <script lang="ts"> -import { defineComponent, useAsync, useRoute } from "@nuxtjs/composition-api"; +import { defineComponent, useRoute } from "@nuxtjs/composition-api"; import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; +import { useLazyRecipes } from "~/composables/recipes"; import { useLoggedInState } from "~/composables/use-logged-in-state"; -import { useUserApi } from "~/composables/api"; -import { useAsyncKey } from "~/composables/use-utils"; export default defineComponent({ components: { RecipeCardSection }, middleware: "auth", setup() { - const api = useUserApi(); const route = useRoute(); const { isOwnGroup } = useLoggedInState(); const userId = route.value.params.id; - const recipes = useAsync(async () => { - const { data } = await api.recipes.getAll(1, -1, { queryFilter: `favoritedBy.id = "${userId}"` }); - return data?.items || null; - }, useAsyncKey()); + const query = { queryFilter: `favoritedBy.id = "${userId}"` } + const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(); return { + query, recipes, isOwnGroup, + appendRecipes, + assignSorted, + removeRecipe, + replaceRecipes, }; }, head() { diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index e864c926e38..41c0cf10317 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import InstrumentedAttribute from typing_extensions import Self +from mealie.db.models.household.household import Household from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.ingredient import RecipeIngredientModel from mealie.db.models.recipe.recipe import RecipeModel @@ -155,6 +156,7 @@ def page_all( # type: ignore tags: list[UUID4 | str] | None = None, tools: list[UUID4 | str] | None = None, foods: list[UUID4 | str] | None = None, + households: list[UUID4 | str] | None = None, require_all_categories=True, require_all_tags=True, require_all_tools=True, @@ -170,6 +172,7 @@ def page_all( # type: ignore if cookbook: cb_filters = self._build_recipe_filter( + households=[cookbook.household_id], categories=extract_uuids(cookbook.categories), tags=extract_uuids(cookbook.tags), tools=extract_uuids(cookbook.tools), @@ -183,11 +186,13 @@ def page_all( # type: ignore category_ids = self._uuids_for_items(categories, Category) tag_ids = self._uuids_for_items(tags, Tag) tool_ids = self._uuids_for_items(tools, Tool) + household_ids = self._uuids_for_items(households, Household) filters = self._build_recipe_filter( categories=category_ids, tags=tag_ids, tools=tool_ids, foods=foods, + households=household_ids, require_all_categories=require_all_categories, require_all_tags=require_all_tags, require_all_tools=require_all_tools, @@ -245,6 +250,7 @@ def _build_recipe_filter( tags: list[UUID4] | None = None, tools: list[UUID4] | None = None, foods: list[UUID4] | None = None, + households: list[UUID4] | None = None, require_all_categories: bool = True, require_all_tags: bool = True, require_all_tools: bool = True, @@ -278,6 +284,8 @@ def _build_recipe_filter( fltr.extend(RecipeModel.recipe_ingredient.any(RecipeIngredientModel.food_id == food) for food in foods) else: fltr.append(RecipeModel.recipe_ingredient.any(RecipeIngredientModel.food_id.in_(foods))) + if households: + fltr.append(RecipeModel.household_id.in_(households)) return fltr def by_category_and_tags( diff --git a/mealie/routes/_base/base_controllers.py b/mealie/routes/_base/base_controllers.py index e5799b182e8..e7d4bd5e28b 100644 --- a/mealie/routes/_base/base_controllers.py +++ b/mealie/routes/_base/base_controllers.py @@ -1,7 +1,7 @@ from abc import ABC from logging import Logger -from fastapi import Depends +from fastapi import Depends, HTTPException from pydantic import UUID4, ConfigDict from sqlalchemy.orm import Session @@ -97,6 +97,12 @@ class BasePublicGroupExploreController(BasePublicController): def group_id(self) -> UUID4 | None | NotSet: return self.group.id + def get_public_household(self, household_slug_or_id: str | UUID4) -> HouseholdInDB: + household = self.repos.households.get_by_slug_or_id(household_slug_or_id) + if not household or household.preferences.private_household: + raise HTTPException(404, "household not found") + return household + def get_explore_url_path(self, endpoint: str) -> str: if endpoint.startswith("/"): endpoint = endpoint[1:] diff --git a/mealie/routes/explore/__init__.py b/mealie/routes/explore/__init__.py index e329fc43f6a..15cc8112d63 100644 --- a/mealie/routes/explore/__init__.py +++ b/mealie/routes/explore/__init__.py @@ -3,6 +3,7 @@ from . import ( controller_public_cookbooks, controller_public_foods, + controller_public_households, controller_public_organizers, controller_public_recipes, ) @@ -11,6 +12,7 @@ # group router.include_router(controller_public_foods.router, tags=["Explore: Foods"]) +router.include_router(controller_public_households.router, tags=["Explore: Households"]) router.include_router(controller_public_organizers.categories_router, tags=["Explore: Categories"]) router.include_router(controller_public_organizers.tags_router, tags=["Explore: Tags"]) router.include_router(controller_public_organizers.tools_router, tags=["Explore: Tools"]) diff --git a/mealie/routes/explore/controller_public_households.py b/mealie/routes/explore/controller_public_households.py new file mode 100644 index 00000000000..2edead21f8d --- /dev/null +++ b/mealie/routes/explore/controller_public_households.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends + +from mealie.routes._base import controller +from mealie.routes._base.base_controllers import BasePublicGroupExploreController +from mealie.schema.household.household import HouseholdSummary +from mealie.schema.make_dependable import make_dependable +from mealie.schema.response.pagination import PaginationBase, PaginationQuery + +router = APIRouter(prefix="/households") + + +@controller(router) +class PublicHouseholdsController(BasePublicGroupExploreController): + @property + def households(self): + return self.repos.households + + @router.get("", response_model=PaginationBase[HouseholdSummary]) + def get_all( + self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)) + ) -> PaginationBase[HouseholdSummary]: + public_filter = "(preferences.private_household = FALSE)" + if q.query_filter: + q.query_filter = f"({q.query_filter}) AND {public_filter}" + else: + q.query_filter = public_filter + + response = self.households.page_all(pagination=q, override=HouseholdSummary) + response.set_pagination_guides(self.get_explore_url_path(router.url_path_for("get_all")), q.model_dump()) + return response + + @router.get("/{household_slug}", response_model=HouseholdSummary) + def get_household(self, household_slug: str) -> HouseholdSummary: + household = self.get_public_household(household_slug) + return household.cast(HouseholdSummary) diff --git a/mealie/routes/explore/controller_public_recipes.py b/mealie/routes/explore/controller_public_recipes.py index 9a443684058..11d1a356d5a 100644 --- a/mealie/routes/explore/controller_public_recipes.py +++ b/mealie/routes/explore/controller_public_recipes.py @@ -37,6 +37,7 @@ def get_all( tags: list[UUID4 | str] | None = Query(None), tools: list[UUID4 | str] | None = Query(None), foods: list[UUID4 | str] | None = Query(None), + households: list[UUID4 | str] | None = Query(None), ) -> PaginationBase[RecipeSummary]: cookbook_data: ReadCookBook | None = None recipes_repo = self.cross_household_recipes @@ -76,6 +77,7 @@ def get_all( tags=tags, tools=tools, foods=foods, + households=households, require_all_categories=search_query.require_all_categories, require_all_tags=search_query.require_all_tags, require_all_tools=search_query.require_all_tools, diff --git a/mealie/routes/groups/__init__.py b/mealie/routes/groups/__init__.py index c42b7915b77..93514285030 100644 --- a/mealie/routes/groups/__init__.py +++ b/mealie/routes/groups/__init__.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from . import ( + controller_group_households, controller_group_reports, controller_group_self_service, controller_labels, @@ -10,6 +11,7 @@ router = APIRouter() +router.include_router(controller_group_households.router) router.include_router(controller_group_self_service.router) router.include_router(controller_migrations.router) router.include_router(controller_group_reports.router) diff --git a/mealie/routes/groups/controller_group_households.py b/mealie/routes/groups/controller_group_households.py new file mode 100644 index 00000000000..3fea64a69ff --- /dev/null +++ b/mealie/routes/groups/controller_group_households.py @@ -0,0 +1,27 @@ +from fastapi import Depends, HTTPException + +from mealie.routes._base.base_controllers import BaseUserController +from mealie.routes._base.controller import controller +from mealie.routes._base.routers import UserAPIRouter +from mealie.schema.household.household import HouseholdSummary +from mealie.schema.response.pagination import PaginationBase, PaginationQuery + +router = UserAPIRouter(prefix="/groups/households", tags=["Groups: Households"]) + + +@controller(router) +class GroupHouseholdsController(BaseUserController): + @router.get("", response_model=PaginationBase[HouseholdSummary]) + def get_all_households(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repos.households.page_all(pagination=q, override=HouseholdSummary) + + response.set_pagination_guides(router.url_path_for("get_all_households"), q.model_dump()) + return response + + @router.get("/{household_slug}", response_model=HouseholdSummary) + def get_one_household(self, household_slug: str): + household = self.repos.households.get_by_slug_or_id(household_slug) + + if not household: + raise HTTPException(status_code=404, detail="Household not found") + return household.cast(HouseholdSummary) diff --git a/mealie/routes/groups/controller_group_self_service.py b/mealie/routes/groups/controller_group_self_service.py index 2df5091bebc..2866329501c 100644 --- a/mealie/routes/groups/controller_group_self_service.py +++ b/mealie/routes/groups/controller_group_self_service.py @@ -1,6 +1,6 @@ from functools import cached_property -from fastapi import HTTPException, Query +from fastapi import Query from pydantic import UUID4 from mealie.routes._base.base_controllers import BaseUserController @@ -8,9 +8,7 @@ from mealie.routes._base.routers import UserAPIRouter from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences from mealie.schema.group.group_statistics import GroupStorage -from mealie.schema.household.household import HouseholdSummary from mealie.schema.response.pagination import PaginationQuery -from mealie.schema.response.responses import ErrorResponse from mealie.schema.user.user import GroupSummary, UserSummary from mealie.services.group_services.group_service import GroupService @@ -36,23 +34,6 @@ def get_group_members(self, household_id: UUID4 | None = Query(None, alias="hous private_users = self.repos.users.page_all(PaginationQuery(page=1, per_page=-1, query_filter=query_filter)).items return [user.cast(UserSummary) for user in private_users] - @router.get("/households", response_model=list[HouseholdSummary]) - def get_group_households(self): - """Returns all households belonging to the current group""" - - households = self.repos.households.page_all(PaginationQuery(page=1, per_page=-1)).items - return [household.cast(HouseholdSummary) for household in households] - - @router.get("/households/{slug}", response_model=HouseholdSummary) - def get_group_household(self, slug: str): - """Returns a single household belonging to the current group""" - - household = self.repos.households.get_by_slug_or_id(slug) - if not household: - raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No Entry Found")) - - return household.cast(HouseholdSummary) - @router.get("/preferences", response_model=ReadGroupPreferences) def get_group_preferences(self): return self.group.preferences diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index f0400c1c0c4..aa3fe8c8222 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -320,6 +320,7 @@ def get_all( tags: list[UUID4 | str] | None = Query(None), tools: list[UUID4 | str] | None = Query(None), foods: list[UUID4 | str] | None = Query(None), + households: list[UUID4 | str] | None = Query(None), ): cookbook_data: ReadCookBook | None = None if search_query.cookbook: @@ -345,6 +346,7 @@ def get_all( tags=tags, tools=tools, foods=foods, + households=households, require_all_categories=search_query.require_all_categories, require_all_tags=search_query.require_all_tags, require_all_tools=search_query.require_all_tools, diff --git a/tests/integration_tests/public_explorer_tests/test_public_households.py b/tests/integration_tests/public_explorer_tests/test_public_households.py new file mode 100644 index 00000000000..23a319d1bd7 --- /dev/null +++ b/tests/integration_tests/public_explorer_tests/test_public_households.py @@ -0,0 +1,81 @@ +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient + +from mealie.schema.household.household import HouseholdCreate +from mealie.schema.household.household_preferences import CreateHouseholdPreferences +from mealie.services.household_services.household_service import HouseholdService +from tests.utils import api_routes +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +@pytest.mark.parametrize("is_private_group", [True, False]) +def test_get_all_households(api_client: TestClient, unique_user: TestUser, is_private_group: bool): + unique_user.repos.group_preferences.patch(UUID(unique_user.group_id), {"private_group": is_private_group}) + households = [ + HouseholdService.create_household( + unique_user.repos, + HouseholdCreate(name=random_string()), + CreateHouseholdPreferences(private_household=False), + ) + for _ in range(5) + ] + + response = api_client.get(api_routes.explore_groups_group_slug_households(unique_user.group_id)) + if is_private_group: + assert response.status_code == 404 + else: + assert response.status_code == 200 + response_ids = [item["id"] for item in response.json()["items"]] + for household in households: + assert str(household.id) in response_ids + + +@pytest.mark.parametrize("is_private_group", [True, False]) +def test_get_all_households_public_only(api_client: TestClient, unique_user: TestUser, is_private_group: bool): + unique_user.repos.group_preferences.patch(UUID(unique_user.group_id), {"private_group": is_private_group}) + public_household = HouseholdService.create_household( + unique_user.repos, + HouseholdCreate(name=random_string()), + CreateHouseholdPreferences(private_household=False), + ) + private_household = HouseholdService.create_household( + unique_user.repos, + HouseholdCreate(name=random_string()), + CreateHouseholdPreferences(private_household=True), + ) + + response = api_client.get(api_routes.explore_groups_group_slug_households(unique_user.group_id)) + if is_private_group: + assert response.status_code == 404 + else: + assert response.status_code == 200 + response_ids = [item["id"] for item in response.json()["items"]] + assert str(public_household.id) in response_ids + assert str(private_household.id) not in response_ids + + +@pytest.mark.parametrize("is_private_group", [True, False]) +@pytest.mark.parametrize("is_private_household", [True, False]) +def test_get_household( + api_client: TestClient, unique_user: TestUser, is_private_group: bool, is_private_household: bool +): + unique_user.repos.group_preferences.patch(UUID(unique_user.group_id), {"private_group": is_private_group}) + household = household = HouseholdService.create_household( + unique_user.repos, + HouseholdCreate(name=random_string()), + CreateHouseholdPreferences(private_household=is_private_household), + ) + + response = api_client.get( + api_routes.explore_groups_group_slug_households_household_slug(unique_user.group_id, household.slug), + headers=unique_user.token, + ) + + if is_private_group or is_private_household: + assert response.status_code == 404 + else: + assert response.status_code == 200 + assert response.json()["id"] == str(household.id) diff --git a/tests/integration_tests/user_group_tests/test_group_self_service.py b/tests/integration_tests/user_group_tests/test_group_self_service.py index 59bfacf33d9..0821030a06b 100644 --- a/tests/integration_tests/user_group_tests/test_group_self_service.py +++ b/tests/integration_tests/user_group_tests/test_group_self_service.py @@ -1,3 +1,5 @@ +import random + from fastapi.testclient import TestClient from mealie.repos.repository_factory import AllRepositories @@ -33,13 +35,10 @@ def test_get_group_members_filtered(api_client: TestClient, unique_user: TestUse assert str(h2_user.user_id) in all_ids -def test_get_households(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser): - households = [ - unfiltered_database.households.create({"name": random_string(), "group_id": unique_user.group_id}) - for _ in range(5) - ] +def test_get_households(api_client: TestClient, unique_user: TestUser): + households = [unique_user.repos.households.create({"name": random_string()}) for _ in range(5)] response = api_client.get(api_routes.groups_households, headers=unique_user.token) - response_ids = [item["id"] for item in response.json()] + response_ids = [item["id"] for item in response.json()["items"]] for household in households: assert str(household.id) in response_ids @@ -58,23 +57,22 @@ def test_get_households_filtered(unfiltered_database: AllRepositories, api_clien ] response = api_client.get(api_routes.groups_households, headers=unique_user.token) - response_ids = [item["id"] for item in response.json()] + response_ids = [item["id"] for item in response.json()["items"]] for household in group_1_households: assert str(household.id) in response_ids for household in group_2_households: assert str(household.id) not in response_ids -def test_get_household(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser): - group_1_id = unique_user.group_id - group_2_id = str(unfiltered_database.groups.create({"name": random_string()}).id) - - group_1_household = unfiltered_database.households.create({"name": random_string(), "group_id": group_1_id}) - group_2_household = unfiltered_database.households.create({"name": random_string(), "group_id": group_2_id}) +def test_get_one_household(api_client: TestClient, unique_user: TestUser): + households = [unique_user.repos.households.create({"name": random_string()}) for _ in range(5)] + household = random.choice(households) - response = api_client.get(api_routes.groups_households_slug(group_1_household.slug), headers=unique_user.token) + response = api_client.get(api_routes.groups_households_household_slug(household.slug), headers=unique_user.token) assert response.status_code == 200 - assert response.json()["id"] == str(group_1_household.id) + assert response.json()["id"] == str(household.id) + - response = api_client.get(api_routes.groups_households_slug(group_2_household.slug), headers=unique_user.token) +def test_get_one_household_not_found(api_client: TestClient, unique_user: TestUser): + response = api_client.get(api_routes.groups_households_household_slug(random_string()), headers=unique_user.token) assert response.status_code == 404 diff --git a/tests/integration_tests/user_household_tests/test_group_cookbooks.py b/tests/integration_tests/user_household_tests/test_group_cookbooks.py index b06decca0ae..11c2b64b1e2 100644 --- a/tests/integration_tests/user_household_tests/test_group_cookbooks.py +++ b/tests/integration_tests/user_household_tests/test_group_cookbooks.py @@ -1,4 +1,5 @@ import random +from collections.abc import Generator from dataclasses import dataclass from uuid import UUID @@ -35,19 +36,20 @@ class TestCookbook: @pytest.fixture(scope="function") -def cookbooks(unique_user: TestUser) -> list[TestCookbook]: +def cookbooks(unique_user: TestUser) -> Generator[list[TestCookbook]]: database = unique_user.repos data: list[ReadCookBook] = [] yield_data: list[TestCookbook] = [] for _ in range(3): cb = database.cookbooks.create(SaveCookBook(**get_page_data(unique_user.group_id, unique_user.household_id))) + assert cb.slug data.append(cb) yield_data.append(TestCookbook(id=cb.id, slug=cb.slug, name=cb.name, data=cb.model_dump())) yield yield_data - for cb in yield_data: + for cb in data: try: database.cookbooks.delete(cb.id) except Exception: diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py b/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py index a04bbb4a321..4528bb21fe7 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py @@ -3,6 +3,9 @@ import pytest from fastapi.testclient import TestClient +from mealie.schema.cookbook.cookbook import SaveCookBook +from mealie.schema.recipe.recipe import Recipe +from mealie.schema.recipe.recipe_category import TagSave from tests.utils import api_routes from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser @@ -65,6 +68,38 @@ def test_get_all_recipes_includes_all_households( assert str(h2_recipe_id) in response_ids +@pytest.mark.parametrize("is_private_household", [True, False]) +def test_get_all_recipes_with_household_filter( + api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool +): + household = unique_user.repos.households.get_one(h2_user.household_id) + assert household and household.preferences + household.preferences.private_household = is_private_household + unique_user.repos.household_preferences.update(household.id, household.preferences) + + response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token) + assert response.status_code == 201 + recipe = unique_user.repos.recipes.get_one(response.json()) + assert recipe and recipe.id + recipe_id = recipe.id + + response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token) + assert response.status_code == 201 + h2_recipe = h2_user.repos.recipes.get_one(response.json()) + assert h2_recipe and h2_recipe.id + h2_recipe_id = h2_recipe.id + + response = api_client.get( + api_routes.recipes, + params={"households": [h2_recipe.household_id], "page": 1, "perPage": -1}, + headers=unique_user.token, + ) + assert response.status_code == 200 + response_ids = {recipe["id"] for recipe in response.json()["items"]} + assert str(recipe_id) not in response_ids + assert str(h2_recipe_id) in response_ids + + @pytest.mark.parametrize("is_private_household", [True, False]) def test_get_one_recipe_from_another_household( api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool @@ -220,3 +255,49 @@ def test_user_can_update_last_made_on_other_household( assert recipe["id"] == str(h2_recipe_id) new_last_made = recipe["lastMade"] assert new_last_made == now != old_last_made + + +def test_cookbook_recipes_only_includes_current_households( + api_client: TestClient, unique_user: TestUser, h2_user: TestUser +): + tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id)) + recipes = unique_user.repos.recipes.create_many( + [ + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(), + tags=[tag], + ) + for _ in range(3) + ] + ) + other_recipes = h2_user.repos.recipes.create_many( + [ + Recipe( + user_id=h2_user.user_id, + group_id=h2_user.group_id, + name=random_string(), + ) + for _ in range(3) + ] + ) + + cookbook = unique_user.repos.cookbooks.create( + SaveCookBook( + name=random_string(), + group_id=unique_user.group_id, + household_id=unique_user.household_id, + tags=[tag], + ) + ) + + response = api_client.get(api_routes.recipes, params={"cookbook": cookbook.slug}, headers=unique_user.token) + assert response.status_code == 200 + recipes = [Recipe.model_validate(data) for data in response.json()["items"]] + + fetched_recipe_ids = {recipe.id for recipe in recipes} + for recipe in recipes: + assert recipe.id in fetched_recipe_ids + for recipe in other_recipes: + assert recipe.id not in fetched_recipe_ids diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py index 779261ed2dc..bf4d343f45f 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -20,6 +20,7 @@ from slugify import slugify from mealie.pkgs.safehttp.transport import AsyncSafeTransport +from mealie.schema.cookbook.cookbook import SaveCookBook from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary, RecipeTag from mealie.schema.recipe.recipe_category import CategorySave, TagSave from mealie.schema.recipe.recipe_notes import RecipeNote @@ -791,3 +792,47 @@ def test_get_random_order(api_client: TestClient, unique_user: utils.TestUser): badparams: dict[str, int | str] = {"page": 1, "perPage": -1, "orderBy": "random"} response = api_client.get(api_routes.recipes, params=badparams, headers=unique_user.token) assert response.status_code == 422 + + +def test_get_cookbook_recipes(api_client: TestClient, unique_user: utils.TestUser): + tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id)) + cookbook_recipes = unique_user.repos.recipes.create_many( + [ + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(), + tags=[tag], + ) + for _ in range(3) + ] + ) + other_recipes = unique_user.repos.recipes.create_many( + [ + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(), + ) + for _ in range(3) + ] + ) + + cookbook = unique_user.repos.cookbooks.create( + SaveCookBook( + name=random_string(), + group_id=unique_user.group_id, + household_id=unique_user.household_id, + tags=[tag], + ) + ) + + response = api_client.get(api_routes.recipes, params={"cookbook": cookbook.slug}, headers=unique_user.token) + assert response.status_code == 200 + recipes = [Recipe.model_validate(data) for data in response.json()["items"]] + + fetched_recipe_ids = {recipe.id for recipe in recipes} + for recipe in cookbook_recipes: + assert recipe.id in fetched_recipe_ids + for recipe in other_recipes: + assert recipe.id not in fetched_recipe_ids diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index b881c34055c..587ca4abef3 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -247,6 +247,16 @@ def explore_groups_group_slug_foods_item_id(group_slug, item_id): return f"{prefix}/explore/groups/{group_slug}/foods/{item_id}" +def explore_groups_group_slug_households(group_slug): + """`/api/explore/groups/{group_slug}/households`""" + return f"{prefix}/explore/groups/{group_slug}/households" + + +def explore_groups_group_slug_households_household_slug(group_slug, household_slug): + """`/api/explore/groups/{group_slug}/households/{household_slug}`""" + return f"{prefix}/explore/groups/{group_slug}/households/{household_slug}" + + def explore_groups_group_slug_organizers_categories(group_slug): """`/api/explore/groups/{group_slug}/organizers/categories`""" return f"{prefix}/explore/groups/{group_slug}/organizers/categories" @@ -292,9 +302,9 @@ def foods_item_id(item_id): return f"{prefix}/foods/{item_id}" -def groups_households_slug(slug): - """`/api/groups/households/{slug}`""" - return f"{prefix}/groups/households/{slug}" +def groups_households_household_slug(household_slug): + """`/api/groups/households/{household_slug}`""" + return f"{prefix}/groups/households/{household_slug}" def groups_labels_item_id(item_id):