feat: Filter Recipes By Household (and a ton of bug fixes) (#4207)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-09-22 09:59:20 -05:00 committed by GitHub
parent 2a6922a85c
commit 7c274de778
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 896 additions and 590 deletions

View File

@ -69,50 +69,52 @@
@toggle-dense-view="toggleMobileCards()" @toggle-dense-view="toggleMobileCards()"
/> />
</v-app-bar> </v-app-bar>
<div v-if="recipes" class="mt-2"> <div v-if="recipes && ready">
<v-row v-if="!useMobileCards"> <div class="mt-2">
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3"> <v-row v-if="!useMobileCards">
<v-lazy> <v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
<RecipeCard <v-lazy>
:name="recipe.name" <RecipeCard
:description="recipe.description" :name="recipe.name"
:slug="recipe.slug" :description="recipe.description"
:rating="recipe.rating" :slug="recipe.slug"
:image="recipe.image" :rating="recipe.rating"
:tags="recipe.tags" :image="recipe.image"
:recipe-id="recipe.id" :tags="recipe.tags"
/> :recipe-id="recipe.id"
</v-lazy> />
</v-col> </v-lazy>
</v-row> </v-col>
<v-row v-else dense> </v-row>
<v-col <v-row v-else dense>
v-for="recipe in recipes" <v-col
:key="recipe.name" v-for="recipe in recipes"
cols="12" :key="recipe.name"
:sm="singleColumn ? '12' : '12'" cols="12"
:md="singleColumn ? '12' : '6'" :sm="singleColumn ? '12' : '12'"
:lg="singleColumn ? '12' : '4'" :md="singleColumn ? '12' : '6'"
:xl="singleColumn ? '12' : '3'" :lg="singleColumn ? '12' : '4'"
> :xl="singleColumn ? '12' : '3'"
<v-lazy> >
<RecipeCardMobile <v-lazy>
:name="recipe.name" <RecipeCardMobile
:description="recipe.description" :name="recipe.name"
:slug="recipe.slug" :description="recipe.description"
:rating="recipe.rating" :slug="recipe.slug"
:image="recipe.image" :rating="recipe.rating"
:tags="recipe.tags" :image="recipe.image"
:recipe-id="recipe.id" :tags="recipe.tags"
/> :recipe-id="recipe.id"
</v-lazy> />
</v-col> </v-lazy>
</v-row> </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> </div>
<v-card v-intersect="infiniteScroll"></v-card>
<v-fade-transition>
<AppLoader v-if="loading" :loading="loading" />
</v-fade-transition>
</div> </div>
</template> </template>
@ -223,36 +225,42 @@ export default defineComponent({
const queryFilter = computed(() => { const queryFilter = computed(() => {
const orderBy = props.query?.orderBy || preferences.value.orderBy; 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) { async function fetchRecipes(pageCount = 1) {
return await fetchMore( return await fetchMore(
page.value, 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, perPage * pageCount,
props.query?.orderBy || preferences.value.orderBy, props.query?.orderBy || preferences.value.orderBy,
props.query?.orderDirection || preferences.value.orderDirection, props.query?.orderDirection || preferences.value.orderDirection,
props.query, 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 queryFilter.value
); );
} }
onMounted(async () => { onMounted(async () => {
if (props.query) { await initRecipes();
await initRecipes(); ready.value = true;
ready.value = true;
}
}); });
let lastQuery: string | undefined; let lastQuery: string | undefined = JSON.stringify(props.query);
watch( watch(
() => props.query, () => props.query,
async (newValue: RecipeSearchQuery | undefined) => { async (newValue: RecipeSearchQuery | undefined) => {
const newValueString = JSON.stringify(newValue) const newValueString = JSON.stringify(newValue)
if (newValue && (!ready.value || lastQuery !== newValueString)) { if (lastQuery !== newValueString) {
lastQuery = newValueString; lastQuery = newValueString;
ready.value = false;
await initRecipes(); await initRecipes();
ready.value = true; ready.value = true;
} }
@ -261,8 +269,12 @@ export default defineComponent({
async function initRecipes() { async function initRecipes() {
page.value = 1; page.value = 1;
const newRecipes = await fetchRecipes(2); hasMore.value = true;
if (!newRecipes.length) {
// 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; hasMore.value = false;
} }
@ -274,7 +286,7 @@ export default defineComponent({
const infiniteScroll = useThrottleFn(() => { const infiniteScroll = useThrottleFn(() => {
useAsync(async () => { useAsync(async () => {
if (!ready.value || !hasMore.value || loading.value) { if (!hasMore.value || loading.value) {
return; return;
} }
@ -282,9 +294,10 @@ export default defineComponent({
page.value = page.value + 1; page.value = page.value + 1;
const newRecipes = await fetchRecipes(); const newRecipes = await fetchRecipes();
if (!newRecipes.length) { if (newRecipes.length < perPage) {
hasMore.value = false; hasMore.value = false;
} else { }
if (newRecipes.length) {
context.emit(APPEND_RECIPES_EVENT, newRecipes); context.emit(APPEND_RECIPES_EVENT, newRecipes);
} }
@ -379,6 +392,7 @@ export default defineComponent({
displayTitleIcon, displayTitleIcon,
EVENTS, EVENTS,
infiniteScroll, infiniteScroll,
ready,
loading, loading,
navigateRandom, navigateRandom,
preferences, preferences,

View File

@ -3,6 +3,8 @@
v-model="selected" v-model="selected"
item-key="id" item-key="id"
show-select show-select
sort-by="dateAdded"
sort-desc
:headers="headers" :headers="headers"
:items="recipes" :items="recipes"
:items-per-page="15" :items-per-page="15"
@ -39,6 +41,9 @@
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
</template> </template>
<template #item.dateAdded="{ item }">
{{ formatDate(item.dateAdded) }}
</template>
</v-data-table> </v-data-table>
</template> </template>
@ -132,6 +137,14 @@ export default defineComponent({
return hdrs; return hdrs;
}); });
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
} catch {
return "";
}
}
// ============ // ============
// Group Members // Group Members
const api = useUserApi(); const api = useUserApi();
@ -160,6 +173,7 @@ export default defineComponent({
groupSlug, groupSlug,
setValue, setValue,
headers, headers,
formatDate,
members, members,
getMember, getMember,
}; };

View File

@ -53,6 +53,14 @@
{{ $t("general.foods") }} {{ $t("general.foods") }}
</SearchFilter> </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 --> <!-- Sort Options -->
<v-menu offset-y nudge-bottom="3"> <v-menu offset-y nudge-bottom="3">
<template #activator="{ on, attrs }"> <template #activator="{ on, attrs }">
@ -142,17 +150,25 @@ import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref,
import { watchDebounced } from "@vueuse/shared"; import { watchDebounced } from "@vueuse/shared";
import SearchFilter from "~/components/Domain/SearchFilter.vue"; import SearchFilter from "~/components/Domain/SearchFilter.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; 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 { useUserSearchQuerySession } from "~/composables/use-users/preferences";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe"; import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useLazyRecipes } from "~/composables/recipes"; import { useLazyRecipes } from "~/composables/recipes";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe"; import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
import { usePublicCategoryStore } from "~/composables/store/use-category-store"; import { HouseholdSummary } from "~/lib/api/types/household";
import { usePublicFoodStore } from "~/composables/store/use-food-store";
import { usePublicTagStore } from "~/composables/store/use-tag-store";
import { usePublicToolStore } from "~/composables/store/use-tool-store";
export default defineComponent({ export default defineComponent({
components: { SearchFilter, RecipeCardSection }, components: { SearchFilter, RecipeCardSection },
@ -186,6 +202,9 @@ export default defineComponent({
const foods = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value); const foods = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
const selectedFoods = ref<IngredientFood[]>([]); 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 tags = isOwnGroup.value ? useTagStore() : usePublicTagStore(groupSlug.value);
const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]); const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
@ -199,6 +218,7 @@ export default defineComponent({
search: state.value.search ? state.value.search : "", search: state.value.search ? state.value.search : "",
categories: toIDArray(selectedCategories.value), categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value), foods: toIDArray(selectedFoods.value),
households: toIDArray(selectedHouseholds.value),
tags: toIDArray(selectedTags.value), tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value), tools: toIDArray(selectedTools.value),
requireAllCategories: state.value.requireAllCategories, requireAllCategories: state.value.requireAllCategories,
@ -239,10 +259,9 @@ export default defineComponent({
state.value.requireAllFoods = queryDefaults.requireAllFoods; state.value.requireAllFoods = queryDefaults.requireAllFoods;
selectedCategories.value = []; selectedCategories.value = [];
selectedFoods.value = []; selectedFoods.value = [];
selectedHouseholds.value = [];
selectedTags.value = []; selectedTags.value = [];
selectedTools.value = []; selectedTools.value = [];
search();
} }
function toggleOrderDirection() { function toggleOrderDirection() {
@ -280,6 +299,7 @@ export default defineComponent({
search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search, search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search,
orderBy: passedQuery.value.orderBy === queryDefaults.orderBy ? undefined : passedQuery.value.orderBy, orderBy: passedQuery.value.orderBy === queryDefaults.orderBy ? undefined : passedQuery.value.orderBy,
orderDirection: passedQuery.value.orderDirection === queryDefaults.orderDirection ? undefined : passedQuery.value.orderDirection, 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, requireAllCategories: passedQuery.value.requireAllCategories ? "true" : undefined,
requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined, requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined,
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined, requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
@ -361,13 +381,10 @@ export default defineComponent({
watch( watch(
() => route.value.query, () => route.value.query,
() => { () => {
if (state.value.ready) { if (!Object.keys(route.value.query).length) {
hydrateSearch(); reset();
} }
}, }
{
deep: true,
},
) )
async function hydrateSearch() { async function hydrateSearch() {
@ -423,9 +440,9 @@ export default defineComponent({
if (query.categories?.length) { if (query.categories?.length) {
promises.push( promises.push(
waitUntilAndExecute( 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) (query.categories as string[]).includes(item.id as string)
); );
@ -440,9 +457,9 @@ export default defineComponent({
if (query.tags?.length) { if (query.tags?.length) {
promises.push( promises.push(
waitUntilAndExecute( 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>[]; selectedTags.value = result as NoUndefinedField<RecipeTag>[];
} }
) )
@ -454,9 +471,9 @@ export default defineComponent({
if (query.tools?.length) { if (query.tools?.length) {
promises.push( promises.push(
waitUntilAndExecute( 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>[]; selectedTools.value = result as NoUndefinedField<RecipeTool>[];
} }
) )
@ -469,13 +486,13 @@ export default defineComponent({
promises.push( promises.push(
waitUntilAndExecute( waitUntilAndExecute(
() => { () => {
if (foods.foods.value) { if (foods.store.value) {
return foods.foods.value.length > 0; return foods.store.value.length > 0;
} }
return false; 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 ?? []; selectedFoods.value = result ?? [];
} }
) )
@ -484,6 +501,25 @@ export default defineComponent({
selectedFoods.value = []; 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); await Promise.allSettled(promises);
}; };
@ -515,6 +551,7 @@ export default defineComponent({
() => state.value.orderDirection, () => state.value.orderDirection,
selectedCategories, selectedCategories,
selectedFoods, selectedFoods,
selectedHouseholds,
selectedTags, selectedTags,
selectedTools, selectedTools,
], ],
@ -533,10 +570,11 @@ export default defineComponent({
search, search,
reset, reset,
state, state,
categories: categories.items as unknown as NoUndefinedField<RecipeCategory>[], categories: categories.store as unknown as NoUndefinedField<RecipeCategory>[],
tags: tags.items as unknown as NoUndefinedField<RecipeTag>[], tags: tags.store as unknown as NoUndefinedField<RecipeTag>[],
foods: foods.foods, foods: foods.store,
tools: tools.items as unknown as NoUndefinedField<RecipeTool>[], tools: tools.store as unknown as NoUndefinedField<RecipeTool>[],
households: households.store as unknown as NoUndefinedField<HouseholdSummary>[],
sortable, sortable,
toggleOrderDirection, toggleOrderDirection,
@ -545,6 +583,7 @@ export default defineComponent({
selectedCategories, selectedCategories,
selectedFoods, selectedFoods,
selectedHouseholds,
selectedTags, selectedTags,
selectedTools, selectedTools,
appendRecipes, appendRecipes,

View File

@ -289,11 +289,11 @@ export default defineComponent({
createAssignFood, createAssignFood,
unitAutocomplete, unitAutocomplete,
createAssignUnit, createAssignUnit,
foods: foodStore.foods, foods: foodStore.store,
foodSearch, foodSearch,
toggleTitle, toggleTitle,
unitActions: unitStore.actions, unitActions: unitStore.actions,
units: unitStore.units, units: unitStore.store,
unitSearch, unitSearch,
validators, validators,
workingUnitData: unitsData.data, workingUnitData: unitsData.data,

View File

@ -135,7 +135,7 @@ export default defineComponent({
await store.actions.createOne({ ...state }); 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); context.emit(CREATED_ITEM_EVENT, newItem);
dialog.value = false; dialog.value = false;

View File

@ -127,9 +127,9 @@ export default defineComponent({
const items = computed(() => { const items = computed(() => {
if (!props.returnObject) { 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) { function removeByIndex(index: number) {

View File

@ -105,7 +105,7 @@ export default defineComponent({
const recipeHousehold = ref<HouseholdSummary>(); const recipeHousehold = ref<HouseholdSummary>();
if (user) { if (user) {
const userApi = useUserApi(); const userApi = useUserApi();
userApi.groups.fetchHousehold(props.recipe.householdId).then(({ data }) => { userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined; recipeHousehold.value = data || undefined;
}); });
} }

View File

@ -11,28 +11,43 @@
<v-card width="400"> <v-card width="400">
<v-card-text> <v-card-text>
<v-text-field v-model="state.search" class="mb-2" hide-details dense :label="$tc('search.search')" clearable /> <v-text-field v-model="state.search" class="mb-2" hide-details dense :label="$tc('search.search')" clearable />
<v-switch <div class="d-flex py-4">
v-if="requireAll != undefined" <v-switch
v-model="requireAllValue" v-if="requireAll != undefined"
dense v-model="requireAllValue"
small dense
:label="`${requireAll ? $tc('search.has-all') : $tc('search.has-any')}`" small
> hide-details
</v-switch> 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-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"> <v-virtual-scroll :items="filtered" height="300" item-height="51">
<template #default="{ item }"> <template #default="{ item }">
<v-list-item :key="item.id" dense :value="item"> <v-list-item :key="item.id" dense :value="item">
<v-list-item-action> <v-list-item-action>
<v-checkbox v-model="selected" :value="item"></v-checkbox> <v-radio v-if="radio" :value="item" @click="handleRadioClick(item)" />
</v-list-item-action> <v-checkbox v-else v-model="selected" :value="item" />
<v-list-item-content> </v-list-item-action>
<v-list-item-title> {{ item.name }}</v-list-item-title> <v-list-item-content>
</v-list-item-content> <v-list-item-title> {{ item.name }} </v-list-item-title>
</v-list-item> </v-list-item-content>
</v-list-item>
<v-divider></v-divider> <v-divider></v-divider>
</template> </template>
</v-virtual-scroll> </v-virtual-scroll>
</v-radio-group>
</v-card> </v-card>
<div v-else> <div v-else>
<v-alert type="info" text> {{ $tc('search.no-results') }} </v-alert> <v-alert type="info" text> {{ $tc('search.no-results') }} </v-alert>
@ -65,6 +80,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: undefined, default: undefined,
}, },
radio: {
type: Boolean,
default: false,
},
}, },
setup(props, context) { setup(props, context) {
const state = reactive({ 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(() => { const filtered = computed(() => {
if (!state.search) { if (!state.search) {
return props.items; return props.items;
@ -94,11 +120,26 @@ export default defineComponent({
return props.items.filter((item) => item.name.toLowerCase().includes(state.search.toLowerCase())); 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 { return {
requireAllValue, requireAllValue,
state, state,
selected, selected,
selectedRadio,
filtered, filtered,
handleRadioClick,
clearSelection,
}; };
}, },
}); });

View File

@ -44,6 +44,8 @@
item-key="id" item-key="id"
:show-select="bulkActions.length > 0" :show-select="bulkActions.length > 0"
:headers="activeHeaders" :headers="activeHeaders"
:sort-by="initialSort"
:sort-desc="initialSortDesc"
:items="data || []" :items="data || []"
:items-per-page="15" :items-per-page="15"
:search="search" :search="search"
@ -126,6 +128,14 @@ export default defineComponent({
type: Array as () => BulkAction[], type: Array as () => BulkAction[],
default: () => [], default: () => [],
}, },
initialSort: {
type: String,
default: "id",
},
initialSortDesc: {
type: Boolean,
default: false,
},
}, },
setup(props, context) { setup(props, context) {
// =========================================================== // ===========================================================

View File

@ -1,3 +1,3 @@
export { useAppInfo } from "./use-app-info"; export { useAppInfo } from "./use-app-info";
export { useStaticRoutes } from "./static-routes"; export { useStaticRoutes } from "./static-routes";
export { useAdminApi, useUserApi } from "./api-client"; export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client";

View File

@ -0,0 +1,3 @@
export type BoundT = {
id?: string | number | null;
};

View File

@ -1,18 +1,15 @@
import { Ref, useAsync } from "@nuxtjs/composition-api"; import { Ref, useAsync } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils"; import { useAsyncKey } from "../use-utils";
import { BoundT } from "./types";
import { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; import { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
import { QueryValue } from "~/lib/api/base/route"; import { QueryValue } from "~/lib/api/base/route";
type BoundT = { interface ReadOnlyStoreActions<T extends BoundT> {
id?: string | number | null;
};
interface PublicStoreActions<T extends BoundT> {
getAll(page?: number, perPage?: number, params?: any): Ref<T[] | null>; getAll(page?: number, perPage?: number, params?: any): Ref<T[] | null>;
refresh(): Promise<void>; 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>; createOne(createData: T): Promise<T | null>;
updateOne(updateData: T): Promise<T | null>; updateOne(updateData: T): Promise<T | null>;
deleteOne(id: string | number): 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 * 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 * Vuex. This is primarily used for basic GET/GETALL operations that required
* a lot of refreshing hooks to be called on operations * 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>, api: BaseCRUDAPIReadOnly<T>,
allRef: Ref<T[] | null> | null, allRef: Ref<T[] | null> | null,
loading: Ref<boolean> loading: Ref<boolean>
): PublicStoreActions<T> { ): ReadOnlyStoreActions<T> {
function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) { function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
params.orderBy ??= "name"; params.orderBy ??= "name";
params.orderDirection ??= "asc"; params.orderDirection ??= "asc";

View File

@ -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 };
}

View File

@ -32,6 +32,7 @@ export const useLazyRecipes = function (publicGroupSlug: string | null = null) {
searchSeed: query?._searchSeed, // unused, but pass it along for completeness of data searchSeed: query?._searchSeed, // unused, but pass it along for completeness of data
search: query?.search, search: query?.search,
cookbook: query?.cookbook, cookbook: query?.cookbook,
households: query?.households,
categories: query?.categories, categories: query?.categories,
requireAllCategories: query?.requireAllCategories, requireAllCategories: query?.requireAllCategories,
tags: query?.tags, tags: query?.tags,

View File

@ -1,6 +1,7 @@
export { useFoodStore, useFoodData } from "./use-food-store"; export { useCategoryStore, usePublicCategoryStore, useCategoryData } from "./use-category-store";
export { useUnitStore, useUnitData } from "./use-unit-store"; export { useFoodStore, usePublicFoodStore, useFoodData } from "./use-food-store";
export { useHouseholdStore, usePublicHouseholdStore } from "./use-household-store";
export { useLabelStore, useLabelData } from "./use-label-store"; export { useLabelStore, useLabelData } from "./use-label-store";
export { useToolStore, useToolData } from "./use-tool-store"; export { useTagStore, usePublicTagStore, useTagData } from "./use-tag-store";
export { useCategoryStore, useCategoryData } from "./use-category-store"; export { useToolStore, usePublicToolStore, useToolData } from "./use-tool-store";
export { useTagStore, useTagData } from "./use-tag-store"; export { useUnitStore, useUnitData } from "./use-unit-store";

View File

@ -1,73 +1,26 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api"; import { ref, Ref } from "@nuxtjs/composition-api";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory"; import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
import { RecipeCategory } from "~/lib/api/types/recipe"; import { RecipeCategory } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
const categoryStore: Ref<RecipeCategory[]> = ref([]); const store: Ref<RecipeCategory[]> = ref([]);
const publicStoreLoading = ref(false); const loading = ref(false);
const storeLoading = ref(false); const publicLoading = ref(false);
export function useCategoryData() { export const useCategoryData = function () {
const data = reactive({ return useData<RecipeCategory>({
id: "", id: "",
name: "", name: "",
slug: undefined, slug: "",
}); });
function reset() {
data.id = "";
data.name = "";
data.slug = undefined;
}
return {
data,
reset,
};
} }
export function usePublicCategoryStore(groupSlug: string) { export const useCategoryStore = function () {
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
const api = useUserApi(); const api = useUserApi();
const loading = storeLoading; return useStore<RecipeCategory>(store, loading, api.categories);
}
const actions = {
...useStoreActions<RecipeCategory>(api.categories, categoryStore, loading), export const usePublicCategoryStore = function (groupSlug: string) {
flushStore() { const api = usePublicExploreApi(groupSlug).explore;
categoryStore.value = []; return useReadOnlyStore<RecipeCategory>(store, publicLoading, api.categories);
},
};
if (!loading.value && (!categoryStore.value || categoryStore.value?.length === 0)) {
actions.getAll();
}
return {
items: categoryStore,
actions,
loading,
};
} }

View File

@ -1,73 +1,28 @@
import { ref, reactive, Ref } from "@nuxtjs/composition-api"; import { ref, Ref } from "@nuxtjs/composition-api";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory"; import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
import { IngredientFood } from "~/lib/api/types/recipe"; import { IngredientFood } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
let foodStore: Ref<IngredientFood[] | null> = ref([]); const store: Ref<IngredientFood[]> = ref([]);
const publicStoreLoading = ref(false); const loading = ref(false);
const storeLoading = 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 () { export const useFoodData = function () {
const data: IngredientFood = reactive({ return useData<IngredientFood>({
id: "", id: "",
name: "", name: "",
description: "", description: "",
labelId: undefined, labelId: undefined,
onHand: false, 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 () { export const useFoodStore = function () {
const api = useUserApi(); const api = useUserApi();
const loading = storeLoading; return useStore<IngredientFood>(store, loading, api.foods);
}
const actions = { export const usePublicFoodStore = function (groupSlug: string) {
...useStoreActions(api.foods, foodStore, loading), const api = usePublicExploreApi(groupSlug).explore;
flushStore() { return useReadOnlyStore<IngredientFood>(store, publicLoading, api.foods);
foodStore.value = []; }
},
};
if (!loading.value && (!foodStore.value || foodStore.value.length === 0)) {
foodStore = actions.getAll();
}
return { foods: foodStore, actions };
};

View File

@ -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);
}

View File

@ -1,50 +1,21 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api"; import { ref, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory"; import { useData, useStore } from "../partials/use-store-factory";
import { MultiPurposeLabelOut } from "~/lib/api/types/labels"; import { MultiPurposeLabelOut } from "~/lib/api/types/labels";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
let labelStore: Ref<MultiPurposeLabelOut[] | null> = ref([]); const store: Ref<MultiPurposeLabelOut[]> = ref([]);
const storeLoading = ref(false); const loading = ref(false);
export function useLabelData() { export const useLabelData = function () {
const data = reactive({ return useData<MultiPurposeLabelOut>({
groupId: "", groupId: "",
id: "", id: "",
name: "", name: "",
color: "", 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 api = useUserApi();
const loading = storeLoading; return useStore<MultiPurposeLabelOut>(store, loading, api.multiPurposeLabels);
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,
};
} }

View File

@ -1,72 +1,26 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api"; import { ref, Ref } from "@nuxtjs/composition-api";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory"; import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
import { RecipeTag } from "~/lib/api/types/recipe"; import { RecipeTag } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
const items: Ref<RecipeTag[]> = ref([]); const store: Ref<RecipeTag[]> = ref([]);
const publicStoreLoading = ref(false); const loading = ref(false);
const storeLoading = ref(false); const publicLoading = ref(false);
export function useTagData() { export const useTagData = function () {
const data = reactive({ return useData<RecipeTag>({
id: "", id: "",
name: "", name: "",
slug: undefined, slug: "",
}); });
function reset() {
data.id = "";
data.name = "";
data.slug = undefined;
}
return {
data,
reset,
};
} }
export function usePublicTagStore(groupSlug: string) { export const useTagStore = function () {
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() {
const api = useUserApi(); const api = useUserApi();
const loading = storeLoading; return useStore<RecipeTag>(store, loading, api.tags);
}
const actions = {
...useStoreActions<RecipeTag>(api.tags, items, loading), export const usePublicTagStore = function (groupSlug: string) {
flushStore() { const api = usePublicExploreApi(groupSlug).explore;
items.value = []; return useReadOnlyStore<RecipeTag>(store, publicLoading, api.tags);
},
};
if (!loading.value && (!items.value || items.value?.length === 0)) {
actions.getAll();
}
return {
items,
actions,
loading,
};
} }

View File

@ -1,74 +1,27 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api"; import { ref, Ref } from "@nuxtjs/composition-api";
import { usePublicExploreApi } from "../api/api-client"; import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { RecipeTool } from "~/lib/api/types/recipe"; import { RecipeTool } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
const toolStore: Ref<RecipeTool[]> = ref([]); const store: Ref<RecipeTool[]> = ref([]);
const publicStoreLoading = ref(false); const loading = ref(false);
const storeLoading = ref(false); const publicLoading = ref(false);
export function useToolData() { export const useToolData = function () {
const data = reactive({ return useData<RecipeTool>({
id: "", id: "",
name: "", name: "",
slug: undefined, slug: "",
onHand: false, onHand: false,
}); });
function reset() {
data.id = "";
data.name = "";
data.slug = undefined;
data.onHand = false;
}
return {
data,
reset,
};
} }
export function usePublicToolStore(groupSlug: string) { export const useToolStore = function () {
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() {
const api = useUserApi(); const api = useUserApi();
const loading = storeLoading; return useStore<RecipeTool>(store, loading, api.tools);
}
const actions = {
...useStoreActions<RecipeTool>(api.tools, toolStore, loading), export const usePublicToolStore = function (groupSlug: string) {
flushStore() { const api = usePublicExploreApi(groupSlug).explore;
toolStore.value = []; return useReadOnlyStore<RecipeTool>(store, publicLoading, api.tools);
},
};
if (!loading.value && (!toolStore.value || toolStore.value?.length === 0)) {
actions.getAll();
}
return {
items: toolStore,
actions,
loading,
};
} }

View File

@ -1,53 +1,22 @@
import { ref, reactive, Ref } from "@nuxtjs/composition-api"; import { ref, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory"; import { useData, useStore } from "../partials/use-store-factory";
import { useUserApi } from "~/composables/api";
import { IngredientUnit } from "~/lib/api/types/recipe"; import { IngredientUnit } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api";
let unitStore: Ref<IngredientUnit[] | null> = ref([]); const store: Ref<IngredientUnit[]> = ref([]);
const storeLoading = ref(false); 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 () { export const useUnitData = function () {
const data: IngredientUnit = reactive({ return useData<IngredientUnit>({
id: "", id: "",
name: "", name: "",
fraction: true, fraction: true,
abbreviation: "", abbreviation: "",
description: "", description: "",
}); });
}
function reset() {
data.id = "";
data.name = "";
data.fraction = true;
data.abbreviation = "";
data.description = "";
}
return {
data,
reset,
};
};
export const useUnitStore = function () { export const useUnitStore = function () {
const api = useUserApi(); const api = useUserApi();
const loading = storeLoading; return useStore<IngredientUnit>(store, loading, api.units);
}
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 };
};

View File

@ -1,5 +1,5 @@
import { computed, ref, Ref, useAsync } from "@nuxtjs/composition-api"; 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"; import { HouseholdCreate, HouseholdInDB } from "~/lib/api/types/household";
const householdSelfRef = ref<HouseholdInDB | null>(null); const householdSelfRef = ref<HouseholdInDB | null>(null);
@ -46,8 +46,8 @@ export const useHouseholdSelf = function () {
return { actions, household }; return { actions, household };
}; };
export const useHouseholds = function () { export const useAdminHouseholds = function () {
const api = useUserApi(); const api = useAdminApi();
const loading = ref(false); const loading = ref(false);
function getAllHouseholds() { function getAllHouseholds() {

View File

@ -7,34 +7,37 @@ const loading = ref(false);
const ready = ref(false); const ready = ref(false);
export const useUserSelfRatings = function () { export const useUserSelfRatings = function () {
const { $auth } = useContext(); const { $auth } = useContext();
const api = useUserApi(); const api = useUserApi();
async function refreshUserRatings() { async function refreshUserRatings() {
if (loading.value) { if (!$auth.user || loading.value) {
return; return;
}
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;
loading.value = true; const { data } = await api.users.getSelfRatings();
const userId = $auth.user?.id || ""; userRatings.value = data?.ratings || [];
await api.users.setRating(userId, slug, rating, isFavorite); loading.value = false;
loading.value = false; ready.value = true;
await refreshUserRatings(); }
}
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(); refreshUserRatings();
return { }
userRatings,
refreshUserRatings, return {
setRating, userRatings,
ready, refreshUserRatings,
} setRating,
ready,
}
} }

View File

@ -652,6 +652,7 @@
"or": "Or", "or": "Or",
"has-any": "Has Any", "has-any": "Has Any",
"has-all": "Has All", "has-all": "Has All",
"clear-selection": "Clear Selection",
"results": "Results", "results": "Results",
"search": "Search", "search": "Search",
"search-mealie": "Search Mealie (press /)", "search-mealie": "Search Mealie (press /)",

View File

@ -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;
}

View File

@ -1,5 +1,6 @@
import { AdminAboutAPI } from "./admin/admin-about"; import { AdminAboutAPI } from "./admin/admin-about";
import { AdminUsersApi } from "./admin/admin-users"; import { AdminUsersApi } from "./admin/admin-users";
import { AdminHouseholdsApi } from "./admin/admin-households";
import { AdminGroupsApi } from "./admin/admin-groups"; import { AdminGroupsApi } from "./admin/admin-groups";
import { AdminBackupsApi } from "./admin/admin-backups"; import { AdminBackupsApi } from "./admin/admin-backups";
import { AdminMaintenanceApi } from "./admin/admin-maintenance"; import { AdminMaintenanceApi } from "./admin/admin-maintenance";
@ -9,6 +10,7 @@ import { ApiRequestInstance } from "~/lib/api/types/non-generated";
export class AdminAPI { export class AdminAPI {
public about: AdminAboutAPI; public about: AdminAboutAPI;
public users: AdminUsersApi; public users: AdminUsersApi;
public households: AdminHouseholdsApi;
public groups: AdminGroupsApi; public groups: AdminGroupsApi;
public backups: AdminBackupsApi; public backups: AdminBackupsApi;
public maintenance: AdminMaintenanceApi; public maintenance: AdminMaintenanceApi;
@ -17,6 +19,7 @@ export class AdminAPI {
constructor(requests: ApiRequestInstance) { constructor(requests: ApiRequestInstance) {
this.about = new AdminAboutAPI(requests); this.about = new AdminAboutAPI(requests);
this.users = new AdminUsersApi(requests); this.users = new AdminUsersApi(requests);
this.households = new AdminHouseholdsApi(requests);
this.groups = new AdminGroupsApi(requests); this.groups = new AdminGroupsApi(requests);
this.backups = new AdminBackupsApi(requests); this.backups = new AdminBackupsApi(requests);
this.maintenance = new AdminMaintenanceApi(requests); this.maintenance = new AdminMaintenanceApi(requests);

View File

@ -4,6 +4,7 @@ import { PublicRecipeApi } from "./explore/recipes";
import { PublicFoodsApi } from "./explore/foods"; import { PublicFoodsApi } from "./explore/foods";
import { PublicCategoriesApi, PublicTagsApi, PublicToolsApi } from "./explore/organizers"; import { PublicCategoriesApi, PublicTagsApi, PublicToolsApi } from "./explore/organizers";
import { PublicCookbooksApi } from "./explore/cookbooks"; import { PublicCookbooksApi } from "./explore/cookbooks";
import { PublicHouseholdApi } from "./explore/households";
export class ExploreApi extends BaseAPI { export class ExploreApi extends BaseAPI {
public recipes: PublicRecipeApi; public recipes: PublicRecipeApi;
@ -12,6 +13,7 @@ export class ExploreApi extends BaseAPI {
public categories: PublicCategoriesApi; public categories: PublicCategoriesApi;
public tags: PublicTagsApi; public tags: PublicTagsApi;
public tools: PublicToolsApi; public tools: PublicToolsApi;
public households: PublicHouseholdApi
constructor(requests: ApiRequestInstance, groupSlug: string) { constructor(requests: ApiRequestInstance, groupSlug: string) {
super(requests); super(requests);
@ -21,5 +23,6 @@ export class ExploreApi extends BaseAPI {
this.categories = new PublicCategoriesApi(requests, groupSlug); this.categories = new PublicCategoriesApi(requests, groupSlug);
this.tags = new PublicTagsApi(requests, groupSlug); this.tags = new PublicTagsApi(requests, groupSlug);
this.tools = new PublicToolsApi(requests, groupSlug); this.tools = new PublicToolsApi(requests, groupSlug);
this.households = new PublicHouseholdApi(requests, groupSlug);
} }
} }

View File

@ -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);
}
}

View File

@ -1,6 +1,5 @@
import { BaseCRUDAPI } from "../base/base-clients"; import { BaseCRUDAPI } from "../base/base-clients";
import { GroupBase, GroupInDB, GroupSummary, UserSummary } from "~/lib/api/types/user"; import { GroupBase, GroupInDB, GroupSummary, UserSummary } from "~/lib/api/types/user";
import { HouseholdSummary } from "~/lib/api/types/household";
import { import {
GroupAdminUpdate, GroupAdminUpdate,
GroupStorage, GroupStorage,
@ -15,8 +14,6 @@ const routes = {
groupsSelf: `${prefix}/groups/self`, groupsSelf: `${prefix}/groups/self`,
preferences: `${prefix}/groups/preferences`, preferences: `${prefix}/groups/preferences`,
storage: `${prefix}/groups/storage`, storage: `${prefix}/groups/storage`,
households: `${prefix}/groups/households`,
householdsId: (id: string | number) => `${prefix}/groups/households/${id}`,
membersHouseholdId: (householdId: string | number | null) => { membersHouseholdId: (householdId: string | number | null) => {
return householdId ? return householdId ?
`${prefix}/households/members?householdId=${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)); 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() { async storage() {
return await this.requests.get<GroupStorage>(routes.storage); return await this.requests.get<GroupStorage>(routes.storage);
} }

View File

@ -1,21 +1,20 @@
import { BaseCRUDAPI } from "../base/base-clients"; import { BaseCRUDAPIReadOnly } from "../base/base-clients";
import { UserOut } from "~/lib/api/types/user"; import { UserOut } from "~/lib/api/types/user";
import { import {
HouseholdCreate,
HouseholdInDB, HouseholdInDB,
UpdateHouseholdAdmin,
HouseholdStatistics, HouseholdStatistics,
ReadHouseholdPreferences, ReadHouseholdPreferences,
SetPermissions, SetPermissions,
UpdateHouseholdPreferences, UpdateHouseholdPreferences,
CreateInviteToken, CreateInviteToken,
ReadInviteToken, ReadInviteToken,
HouseholdSummary,
} from "~/lib/api/types/household"; } from "~/lib/api/types/household";
const prefix = "/api"; const prefix = "/api";
const routes = { const routes = {
households: `${prefix}/admin/households`, households: `${prefix}/groups/households`,
householdsSelf: `${prefix}/households/self`, householdsSelf: `${prefix}/households/self`,
members: `${prefix}/households/members`, members: `${prefix}/households/members`,
permissions: `${prefix}/households/permissions`, permissions: `${prefix}/households/permissions`,
@ -24,13 +23,13 @@ const routes = {
statistics: `${prefix}/households/statistics`, statistics: `${prefix}/households/statistics`,
invitation: `${prefix}/households/invitations`, 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; baseRoute = routes.households;
itemRoute = routes.householdsId; itemRoute = routes.householdsId;
/** Returns the Group Data for the Current User /** Returns the Household Data for the Current User
*/ */
async getCurrentUserHousehold() { async getCurrentUserHousehold() {
return await this.requests.get<HouseholdInDB>(routes.householdsSelf); return await this.requests.get<HouseholdInDB>(routes.householdsSelf);

View File

@ -56,13 +56,14 @@ const routes = {
}; };
export type RecipeSearchQuery = { export type RecipeSearchQuery = {
search: string; search?: string;
orderDirection?: "asc" | "desc"; orderDirection?: "asc" | "desc";
groupId?: string; groupId?: string;
queryFilter?: string; queryFilter?: string;
cookbook?: string; cookbook?: string;
households?: string[];
categories?: string[]; categories?: string[];
requireAllCategories?: boolean; requireAllCategories?: boolean;

View File

@ -45,7 +45,7 @@
import { defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api"; import { defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api";
import HouseholdPreferencesEditor from "~/components/Domain/Household/HouseholdPreferencesEditor.vue"; import HouseholdPreferencesEditor from "~/components/Domain/Household/HouseholdPreferencesEditor.vue";
import { useGroups } from "~/composables/use-groups"; import { useGroups } from "~/composables/use-groups";
import { useUserApi } from "~/composables/api"; import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { HouseholdInDB } from "~/lib/api/types/household"; import { HouseholdInDB } from "~/lib/api/types/household";
@ -68,14 +68,14 @@ export default defineComponent({
const refHouseholdEditForm = ref<VForm | null>(null); const refHouseholdEditForm = ref<VForm | null>(null);
const userApi = useUserApi(); const adminApi = useAdminApi();
const household = ref<HouseholdInDB | null>(null); const household = ref<HouseholdInDB | null>(null);
const userError = ref(false); const userError = ref(false);
onMounted(async () => { onMounted(async () => {
const { data, error } = await userApi.households.getOne(householdId); const { data, error } = await adminApi.households.getOne(householdId);
if (error?.response?.status === 404) { if (error?.response?.status === 404) {
alert.error(i18n.tc("user.user-not-found")); alert.error(i18n.tc("user.user-not-found"));
@ -92,7 +92,7 @@ export default defineComponent({
return; 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) { if (response?.status === 200 && data) {
household.value = data; household.value = data;
alert.success(i18n.tc("settings.settings-updated")); alert.success(i18n.tc("settings.settings-updated"));

View File

@ -88,7 +88,7 @@
import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api"; import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { fieldTypes } from "~/composables/forms"; import { fieldTypes } from "~/composables/forms";
import { useGroups } from "~/composables/use-groups"; 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 { validators } from "~/composables/use-validators";
import { HouseholdInDB } from "~/lib/api/types/household"; import { HouseholdInDB } from "~/lib/api/types/household";
@ -97,7 +97,7 @@ export default defineComponent({
setup() { setup() {
const { i18n } = useContext(); const { i18n } = useContext();
const { groups } = useGroups(); const { groups } = useGroups();
const { households, refreshAllHouseholds, deleteHousehold, createHousehold } = useHouseholds(); const { households, refreshAllHouseholds, deleteHousehold, createHousehold } = useAdminHouseholds();
const state = reactive({ const state = reactive({
createDialog: false, createDialog: false,

View File

@ -80,7 +80,7 @@
import { computed, defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api"; import { computed, defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api";
import { useAdminApi, useUserApi } from "~/composables/api"; import { useAdminApi, useUserApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups"; 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 { alert } from "~/composables/use-toast";
import { useUserForm } from "~/composables/use-users"; import { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
@ -92,7 +92,7 @@ export default defineComponent({
setup() { setup() {
const { userForm } = useUserForm(); const { userForm } = useUserForm();
const { groups } = useGroups(); const { groups } = useGroups();
const { useHouseholdsInGroup } = useHouseholds(); const { useHouseholdsInGroup } = useAdminHouseholds();
const { i18n } = useContext(); const { i18n } = useContext();
const route = useRoute(); const route = useRoute();

View File

@ -50,7 +50,7 @@
import { computed, defineComponent, useRouter, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api"; import { computed, defineComponent, useRouter, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api"; import { useAdminApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups"; 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 { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { VForm } from "~/types/vuetify"; import { VForm } from "~/types/vuetify";
@ -60,7 +60,7 @@ export default defineComponent({
setup() { setup() {
const { userForm } = useUserForm(); const { userForm } = useUserForm();
const { groups } = useGroups(); const { groups } = useGroups();
const { useHouseholdsInGroup } = useHouseholds(); const { useHouseholdsInGroup } = useAdminHouseholds();
const router = useRouter(); const router = useRouter();
// ============================================== // ==============================================

View File

@ -94,7 +94,7 @@
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, useContext, useRouter } from "@nuxtjs/composition-api"; 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 { useLocales } from "~/composables/use-locales";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form"; import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
@ -108,7 +108,8 @@ export default defineComponent({
// ================================================================ // ================================================================
// Setup // Setup
const { $auth, $globals, i18n } = useContext(); const { $auth, $globals, i18n } = useContext();
const api = useUserApi(); const userApi = useUserApi();
const adminApi = useAdminApi();
const groupSlug = computed(() => $auth.user?.groupSlug); const groupSlug = computed(() => $auth.user?.groupSlug);
const { locale } = useLocales(); const { locale } = useLocales();
@ -264,7 +265,7 @@ export default defineComponent({
async function updateUser() { async function updateUser() {
// @ts-ignore-next-line user will never be null here // @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, ...$auth.user,
email: accountDetails.email.value, email: accountDetails.email.value,
username: accountDetails.username.value, username: accountDetails.username.value,
@ -285,7 +286,7 @@ export default defineComponent({
} }
async function updatePassword() { async function updatePassword() {
const { response } = await api.users.changePassword({ const { response } = await userApi.users.changePassword({
currentPassword: "MyPassword", currentPassword: "MyPassword",
newPassword: credentials.password1.value, newPassword: credentials.password1.value,
}); });
@ -303,7 +304,7 @@ export default defineComponent({
async function updateGroup() { async function updateGroup() {
// @ts-ignore-next-line user will never be null here // @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) { if (!data || !data.preferences) {
alert.error(i18n.tc("events.something-went-wrong")); alert.error(i18n.tc("events.something-went-wrong"));
return; return;
@ -320,7 +321,7 @@ export default defineComponent({
} }
// @ts-ignore-next-line user will never be null here // @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) { if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong")); alert.error(i18n.tc("events.something-went-wrong"));
} }
@ -328,7 +329,7 @@ export default defineComponent({
async function updateHousehold() { async function updateHousehold() {
// @ts-ignore-next-line user will never be null here // @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) { if (!data || !data.preferences) {
alert.error(i18n.tc("events.something-went-wrong")); alert.error(i18n.tc("events.something-went-wrong"));
return; return;
@ -346,28 +347,28 @@ export default defineComponent({
} }
// @ts-ignore-next-line user will never be null here // @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) { if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong")); alert.error(i18n.tc("events.something-went-wrong"));
} }
} }
async function seedFoods() { 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) { if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong")); alert.error(i18n.tc("events.something-went-wrong"));
} }
} }
async function seedUnits() { 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) { if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong")); alert.error(i18n.tc("events.something-went-wrong"));
} }
} }
async function seedLabels() { 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) { if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong")); alert.error(i18n.tc("events.something-went-wrong"));
} }

View File

@ -272,12 +272,10 @@ export default defineComponent({
const errors = ref<Error[]>([]); const errors = ref<Error[]>([]);
function checkForUnit(unit?: IngredientUnit | CreateIngredientUnit) { 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; return !!unit?.id;
} }
function checkForFood(food?: IngredientFood | CreateIngredientFood) { 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; return !!food?.id;
} }

View File

@ -1,8 +1,8 @@
<template> <template>
<v-container> <v-container>
<RecipeOrganizerPage <RecipeOrganizerPage
v-if="items" v-if="store"
:items="items" :items="store"
:icon="$globals.icons.categories" :icon="$globals.icons.categories"
item-type="categories" item-type="categories"
@delete="actions.deleteOne" @delete="actions.deleteOne"
@ -24,10 +24,10 @@ export default defineComponent({
}, },
middleware: ["auth", "group-only"], middleware: ["auth", "group-only"],
setup() { setup() {
const { items, actions } = useCategoryStore(); const { store, actions } = useCategoryStore();
return { return {
items, store,
actions, actions,
}; };
}, },

View File

@ -1,8 +1,8 @@
<template> <template>
<v-container> <v-container>
<RecipeOrganizerPage <RecipeOrganizerPage
v-if="items" v-if="store"
:items="items" :items="store"
:icon="$globals.icons.tags" :icon="$globals.icons.tags"
item-type="tags" item-type="tags"
@delete="actions.deleteOne" @delete="actions.deleteOne"
@ -24,10 +24,10 @@ export default defineComponent({
}, },
middleware: ["auth", "group-only"], middleware: ["auth", "group-only"],
setup() { setup() {
const { items, actions } = useTagStore(); const { store, actions } = useTagStore();
return { return {
items, store,
actions, actions,
}; };
}, },

View File

@ -29,7 +29,7 @@ export default defineComponent({
return { return {
dialog, dialog,
tools: toolStore.items, tools: toolStore.store,
actions: toolStore.actions, actions: toolStore.actions,
}; };
}, },

View File

@ -81,6 +81,7 @@
:headers.sync="tableHeaders" :headers.sync="tableHeaders"
:data="categories || []" :data="categories || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="name"
@delete-one="deleteEventHandler" @delete-one="deleteEventHandler"
@edit-one="editEventHandler" @edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler" @delete-selected="bulkDeleteEventHandler"
@ -198,7 +199,7 @@ export default defineComponent({
state, state,
tableConfig, tableConfig,
tableHeaders, tableHeaders,
categories: categoryStore.items, categories: categoryStore.store,
validators, validators,
// create // create

View File

@ -241,6 +241,8 @@
{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}, {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'} {icon: $globals.icons.tags, text: $tc('data-pages.labels.assign-label'), event: 'assign-selected'}
]" ]"
initial-sort="createdAt"
initial-sort-desc
@delete-one="deleteEventHandler" @delete-one="deleteEventHandler"
@edit-one="editEventHandler" @edit-one="editEventHandler"
@create-one="createEventHandler" @create-one="createEventHandler"
@ -264,6 +266,9 @@
{{ item.onHand ? $globals.icons.check : $globals.icons.close }} {{ item.onHand ? $globals.icons.check : $globals.icons.close }}
</v-icon> </v-icon>
</template> </template>
<template #item.createdAt="{ item }">
{{ formatDate(item.createdAt) }}
</template>
<template #button-bottom> <template #button-bottom>
<BaseButton @click="seedDialog = true"> <BaseButton @click="seedDialog = true">
<template #icon> {{ $globals.icons.database }} </template> <template #icon> {{ $globals.icons.database }} </template>
@ -326,8 +331,21 @@ export default defineComponent({
value: "onHand", value: "onHand",
show: true, 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(); const foodStore = useFoodStore();
// =============================================================== // ===============================================================
@ -453,7 +471,7 @@ export default defineComponent({
// ============================================================ // ============================================================
// Labels // Labels
const { labels: allLabels } = useLabelStore(); const { store: allLabels } = useLabelStore();
// ============================================================ // ============================================================
// Seed // Seed
@ -501,16 +519,15 @@ export default defineComponent({
bulkAssignTarget.value = []; bulkAssignTarget.value = [];
bulkAssignLabelId.value = undefined; bulkAssignLabelId.value = undefined;
foodStore.actions.refresh(); 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 { return {
tableConfig, tableConfig,
tableHeaders, tableHeaders,
foods: foodStore.foods, foods: foodStore.store,
allLabels, allLabels,
validators, validators,
formatDate,
// Create // Create
createDialog, createDialog,
domNewFoodForm, domNewFoodForm,

View File

@ -115,6 +115,7 @@
:headers.sync="tableHeaders" :headers.sync="tableHeaders"
:data="labels || []" :data="labels || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="name"
@delete-one="deleteEventHandler" @delete-one="deleteEventHandler"
@edit-one="editEventHandler" @edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler" @delete-selected="bulkDeleteEventHandler"
@ -271,7 +272,7 @@ export default defineComponent({
state, state,
tableConfig, tableConfig,
tableHeaders, tableHeaders,
labels: labelStore.labels, labels: labelStore.store,
validators, validators,
// create // create

View File

@ -101,6 +101,7 @@
:headers.sync="tableHeaders" :headers.sync="tableHeaders"
:data="actions || []" :data="actions || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="title"
@delete-one="deleteEventHandler" @delete-one="deleteEventHandler"
@edit-one="editEventHandler" @edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler" @delete-selected="bulkDeleteEventHandler"

View File

@ -81,6 +81,7 @@
:headers.sync="tableHeaders" :headers.sync="tableHeaders"
:data="tags || []" :data="tags || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="name"
@delete-one="deleteEventHandler" @delete-one="deleteEventHandler"
@edit-one="editEventHandler" @edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler" @delete-selected="bulkDeleteEventHandler"
@ -199,7 +200,7 @@ export default defineComponent({
state, state,
tableConfig, tableConfig,
tableHeaders, tableHeaders,
tags: tagStore.items, tags: tagStore.store,
validators, validators,
// create // create

View File

@ -83,6 +83,7 @@
:headers.sync="tableHeaders" :headers.sync="tableHeaders"
:data="tools || []" :data="tools || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="name"
@delete-one="deleteEventHandler" @delete-one="deleteEventHandler"
@edit-one="editEventHandler" @edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler" @delete-selected="bulkDeleteEventHandler"
@ -209,7 +210,7 @@ export default defineComponent({
state, state,
tableConfig, tableConfig,
tableHeaders, tableHeaders,
tools: toolStore.items, tools: toolStore.store,
validators, validators,
// create // create

View File

@ -9,11 +9,11 @@
</template> </template>
</i18n> </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 #selection="{ item }"> {{ item.name }}</template>
<template #item="{ item }"> {{ item.name }} </template> <template #item="{ item }"> {{ item.name }} </template>
</v-autocomplete> </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 #selection="{ item }"> {{ item.name }}</template>
<template #item="{ item }"> {{ item.name }} </template> <template #item="{ item }"> {{ item.name }} </template>
</v-autocomplete> </v-autocomplete>
@ -185,7 +185,7 @@
</template> </template>
</v-autocomplete> </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") }} {{ $t("data-pages.foods.seed-dialog-warning") }}
</v-alert> </v-alert>
</v-card-text> </v-card-text>
@ -196,8 +196,10 @@
<CrudTable <CrudTable
:table-config="tableConfig" :table-config="tableConfig"
:headers.sync="tableHeaders" :headers.sync="tableHeaders"
:data="units || []" :data="store"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]" :bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="createdAt"
initial-sort-desc
@delete-one="deleteEventHandler" @delete-one="deleteEventHandler"
@edit-one="editEventHandler" @edit-one="editEventHandler"
@create-one="createEventHandler" @create-one="createEventHandler"
@ -221,6 +223,9 @@
{{ item.fraction ? $globals.icons.check : $globals.icons.close }} {{ item.fraction ? $globals.icons.check : $globals.icons.close }}
</v-icon> </v-icon>
</template> </template>
<template #item.createdAt="{ item }">
{{ formatDate(item.createdAt) }}
</template>
<template #button-bottom> <template #button-bottom>
<BaseButton @click="seedDialog = true"> <BaseButton @click="seedDialog = true">
<template #icon> {{ $globals.icons.database }} </template> <template #icon> {{ $globals.icons.database }} </template>
@ -292,9 +297,22 @@ export default defineComponent({
value: "fraction", value: "fraction",
show: true, 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 // Create Units
@ -447,8 +465,9 @@ export default defineComponent({
return { return {
tableConfig, tableConfig,
tableHeaders, tableHeaders,
units, store,
validators, validators,
formatDate,
// Create // Create
createDialog, createDialog,
domNewUnitForm, domNewUnitForm,

View File

@ -602,9 +602,9 @@ export default defineComponent({
const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>() const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>()
const { labels: allLabels } = useLabelStore(); const { store: allLabels } = useLabelStore();
const { units: allUnits } = useUnitStore(); const { store: allUnits } = useUnitStore();
const { foods: allFoods } = useFoodStore(); const { store: allFoods } = useFoodStore();
function getLabelColor(item: ShoppingListItemOut | null) { function getLabelColor(item: ShoppingListItemOut | null) {
return item?.label?.color; return item?.label?.color;

View File

@ -5,34 +5,40 @@
:icon="$globals.icons.heart" :icon="$globals.icons.heart"
:title="$tc('user.user-favorites')" :title="$tc('user.user-favorites')"
:recipes="recipes" :recipes="recipes"
:query="query"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@delete="removeRecipe"
/> />
</v-container> </v-container>
</template> </template>
<script lang="ts"> <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 RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useLazyRecipes } from "~/composables/recipes";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
export default defineComponent({ export default defineComponent({
components: { RecipeCardSection }, components: { RecipeCardSection },
middleware: "auth", middleware: "auth",
setup() { setup() {
const api = useUserApi();
const route = useRoute(); const route = useRoute();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const userId = route.value.params.id; const userId = route.value.params.id;
const recipes = useAsync(async () => { const query = { queryFilter: `favoritedBy.id = "${userId}"` }
const { data } = await api.recipes.getAll(1, -1, { queryFilter: `favoritedBy.id = "${userId}"` }); const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
return data?.items || null;
}, useAsyncKey());
return { return {
query,
recipes, recipes,
isOwnGroup, isOwnGroup,
appendRecipes,
assignSorted,
removeRecipe,
replaceRecipes,
}; };
}, },
head() { head() {

View File

@ -10,6 +10,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.orm import InstrumentedAttribute
from typing_extensions import Self 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.category import Category
from mealie.db.models.recipe.ingredient import RecipeIngredientModel from mealie.db.models.recipe.ingredient import RecipeIngredientModel
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
@ -155,6 +156,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
tags: list[UUID4 | str] | None = None, tags: list[UUID4 | str] | None = None,
tools: list[UUID4 | str] | None = None, tools: list[UUID4 | str] | None = None,
foods: list[UUID4 | str] | None = None, foods: list[UUID4 | str] | None = None,
households: list[UUID4 | str] | None = None,
require_all_categories=True, require_all_categories=True,
require_all_tags=True, require_all_tags=True,
require_all_tools=True, require_all_tools=True,
@ -170,6 +172,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
if cookbook: if cookbook:
cb_filters = self._build_recipe_filter( cb_filters = self._build_recipe_filter(
households=[cookbook.household_id],
categories=extract_uuids(cookbook.categories), categories=extract_uuids(cookbook.categories),
tags=extract_uuids(cookbook.tags), tags=extract_uuids(cookbook.tags),
tools=extract_uuids(cookbook.tools), tools=extract_uuids(cookbook.tools),
@ -183,11 +186,13 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
category_ids = self._uuids_for_items(categories, Category) category_ids = self._uuids_for_items(categories, Category)
tag_ids = self._uuids_for_items(tags, Tag) tag_ids = self._uuids_for_items(tags, Tag)
tool_ids = self._uuids_for_items(tools, Tool) tool_ids = self._uuids_for_items(tools, Tool)
household_ids = self._uuids_for_items(households, Household)
filters = self._build_recipe_filter( filters = self._build_recipe_filter(
categories=category_ids, categories=category_ids,
tags=tag_ids, tags=tag_ids,
tools=tool_ids, tools=tool_ids,
foods=foods, foods=foods,
households=household_ids,
require_all_categories=require_all_categories, require_all_categories=require_all_categories,
require_all_tags=require_all_tags, require_all_tags=require_all_tags,
require_all_tools=require_all_tools, require_all_tools=require_all_tools,
@ -245,6 +250,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
tags: list[UUID4] | None = None, tags: list[UUID4] | None = None,
tools: list[UUID4] | None = None, tools: list[UUID4] | None = None,
foods: list[UUID4] | None = None, foods: list[UUID4] | None = None,
households: list[UUID4] | None = None,
require_all_categories: bool = True, require_all_categories: bool = True,
require_all_tags: bool = True, require_all_tags: bool = True,
require_all_tools: bool = True, require_all_tools: bool = True,
@ -278,6 +284,8 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
fltr.extend(RecipeModel.recipe_ingredient.any(RecipeIngredientModel.food_id == food) for food in foods) fltr.extend(RecipeModel.recipe_ingredient.any(RecipeIngredientModel.food_id == food) for food in foods)
else: else:
fltr.append(RecipeModel.recipe_ingredient.any(RecipeIngredientModel.food_id.in_(foods))) fltr.append(RecipeModel.recipe_ingredient.any(RecipeIngredientModel.food_id.in_(foods)))
if households:
fltr.append(RecipeModel.household_id.in_(households))
return fltr return fltr
def by_category_and_tags( def by_category_and_tags(

View File

@ -1,7 +1,7 @@
from abc import ABC from abc import ABC
from logging import Logger from logging import Logger
from fastapi import Depends from fastapi import Depends, HTTPException
from pydantic import UUID4, ConfigDict from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -97,6 +97,12 @@ class BasePublicGroupExploreController(BasePublicController):
def group_id(self) -> UUID4 | None | NotSet: def group_id(self) -> UUID4 | None | NotSet:
return self.group.id 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: def get_explore_url_path(self, endpoint: str) -> str:
if endpoint.startswith("/"): if endpoint.startswith("/"):
endpoint = endpoint[1:] endpoint = endpoint[1:]

View File

@ -3,6 +3,7 @@ from fastapi import APIRouter
from . import ( from . import (
controller_public_cookbooks, controller_public_cookbooks,
controller_public_foods, controller_public_foods,
controller_public_households,
controller_public_organizers, controller_public_organizers,
controller_public_recipes, controller_public_recipes,
) )
@ -11,6 +12,7 @@ router = APIRouter(prefix="/explore/groups/{group_slug}")
# group # group
router.include_router(controller_public_foods.router, tags=["Explore: Foods"]) 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.categories_router, tags=["Explore: Categories"])
router.include_router(controller_public_organizers.tags_router, tags=["Explore: Tags"]) router.include_router(controller_public_organizers.tags_router, tags=["Explore: Tags"])
router.include_router(controller_public_organizers.tools_router, tags=["Explore: Tools"]) router.include_router(controller_public_organizers.tools_router, tags=["Explore: Tools"])

View File

@ -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)

View File

@ -37,6 +37,7 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
tags: list[UUID4 | str] | None = Query(None), tags: list[UUID4 | str] | None = Query(None),
tools: list[UUID4 | str] | None = Query(None), tools: list[UUID4 | str] | None = Query(None),
foods: list[UUID4 | str] | None = Query(None), foods: list[UUID4 | str] | None = Query(None),
households: list[UUID4 | str] | None = Query(None),
) -> PaginationBase[RecipeSummary]: ) -> PaginationBase[RecipeSummary]:
cookbook_data: ReadCookBook | None = None cookbook_data: ReadCookBook | None = None
recipes_repo = self.cross_household_recipes recipes_repo = self.cross_household_recipes
@ -76,6 +77,7 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
tags=tags, tags=tags,
tools=tools, tools=tools,
foods=foods, foods=foods,
households=households,
require_all_categories=search_query.require_all_categories, require_all_categories=search_query.require_all_categories,
require_all_tags=search_query.require_all_tags, require_all_tags=search_query.require_all_tags,
require_all_tools=search_query.require_all_tools, require_all_tools=search_query.require_all_tools,

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from . import ( from . import (
controller_group_households,
controller_group_reports, controller_group_reports,
controller_group_self_service, controller_group_self_service,
controller_labels, controller_labels,
@ -10,6 +11,7 @@ from . import (
router = APIRouter() router = APIRouter()
router.include_router(controller_group_households.router)
router.include_router(controller_group_self_service.router) router.include_router(controller_group_self_service.router)
router.include_router(controller_migrations.router) router.include_router(controller_migrations.router)
router.include_router(controller_group_reports.router) router.include_router(controller_group_reports.router)

View File

@ -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)

View File

@ -1,6 +1,6 @@
from functools import cached_property from functools import cached_property
from fastapi import HTTPException, Query from fastapi import Query
from pydantic import UUID4 from pydantic import UUID4
from mealie.routes._base.base_controllers import BaseUserController from mealie.routes._base.base_controllers import BaseUserController
@ -8,9 +8,7 @@ from mealie.routes._base.controller import controller
from mealie.routes._base.routers import UserAPIRouter from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.schema.group.group_statistics import GroupStorage 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.pagination import PaginationQuery
from mealie.schema.response.responses import ErrorResponse
from mealie.schema.user.user import GroupSummary, UserSummary from mealie.schema.user.user import GroupSummary, UserSummary
from mealie.services.group_services.group_service import GroupService from mealie.services.group_services.group_service import GroupService
@ -36,23 +34,6 @@ class GroupSelfServiceController(BaseUserController):
private_users = self.repos.users.page_all(PaginationQuery(page=1, per_page=-1, query_filter=query_filter)).items 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] 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) @router.get("/preferences", response_model=ReadGroupPreferences)
def get_group_preferences(self): def get_group_preferences(self):
return self.group.preferences return self.group.preferences

View File

@ -320,6 +320,7 @@ class RecipeController(BaseRecipeController):
tags: list[UUID4 | str] | None = Query(None), tags: list[UUID4 | str] | None = Query(None),
tools: list[UUID4 | str] | None = Query(None), tools: list[UUID4 | str] | None = Query(None),
foods: 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 cookbook_data: ReadCookBook | None = None
if search_query.cookbook: if search_query.cookbook:
@ -345,6 +346,7 @@ class RecipeController(BaseRecipeController):
tags=tags, tags=tags,
tools=tools, tools=tools,
foods=foods, foods=foods,
households=households,
require_all_categories=search_query.require_all_categories, require_all_categories=search_query.require_all_categories,
require_all_tags=search_query.require_all_tags, require_all_tags=search_query.require_all_tags,
require_all_tools=search_query.require_all_tools, require_all_tools=search_query.require_all_tools,

View File

@ -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)

View File

@ -1,3 +1,5 @@
import random
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories 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 assert str(h2_user.user_id) in all_ids
def test_get_households(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser): def test_get_households(api_client: TestClient, unique_user: TestUser):
households = [ households = [unique_user.repos.households.create({"name": random_string()}) for _ in range(5)]
unfiltered_database.households.create({"name": random_string(), "group_id": unique_user.group_id})
for _ in range(5)
]
response = api_client.get(api_routes.groups_households, headers=unique_user.token) 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: for household in households:
assert str(household.id) in response_ids 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 = 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: for household in group_1_households:
assert str(household.id) in response_ids assert str(household.id) in response_ids
for household in group_2_households: for household in group_2_households:
assert str(household.id) not in response_ids assert str(household.id) not in response_ids
def test_get_household(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser): def test_get_one_household(api_client: TestClient, unique_user: TestUser):
group_1_id = unique_user.group_id households = [unique_user.repos.households.create({"name": random_string()}) for _ in range(5)]
group_2_id = str(unfiltered_database.groups.create({"name": random_string()}).id) household = random.choice(households)
group_1_household = unfiltered_database.households.create({"name": random_string(), "group_id": group_1_id}) response = api_client.get(api_routes.groups_households_household_slug(household.slug), headers=unique_user.token)
group_2_household = unfiltered_database.households.create({"name": random_string(), "group_id": group_2_id})
response = api_client.get(api_routes.groups_households_slug(group_1_household.slug), headers=unique_user.token)
assert response.status_code == 200 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 assert response.status_code == 404

View File

@ -1,4 +1,5 @@
import random import random
from collections.abc import Generator
from dataclasses import dataclass from dataclasses import dataclass
from uuid import UUID from uuid import UUID
@ -35,19 +36,20 @@ class TestCookbook:
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def cookbooks(unique_user: TestUser) -> list[TestCookbook]: def cookbooks(unique_user: TestUser) -> Generator[list[TestCookbook]]:
database = unique_user.repos database = unique_user.repos
data: list[ReadCookBook] = [] data: list[ReadCookBook] = []
yield_data: list[TestCookbook] = [] yield_data: list[TestCookbook] = []
for _ in range(3): for _ in range(3):
cb = database.cookbooks.create(SaveCookBook(**get_page_data(unique_user.group_id, unique_user.household_id))) cb = database.cookbooks.create(SaveCookBook(**get_page_data(unique_user.group_id, unique_user.household_id)))
assert cb.slug
data.append(cb) data.append(cb)
yield_data.append(TestCookbook(id=cb.id, slug=cb.slug, name=cb.name, data=cb.model_dump())) yield_data.append(TestCookbook(id=cb.id, slug=cb.slug, name=cb.name, data=cb.model_dump()))
yield yield_data yield yield_data
for cb in yield_data: for cb in data:
try: try:
database.cookbooks.delete(cb.id) database.cookbooks.delete(cb.id)
except Exception: except Exception:

View File

@ -3,6 +3,9 @@ from datetime import datetime, timezone
import pytest import pytest
from fastapi.testclient import TestClient 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 import api_routes
from tests.utils.factories import random_string from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser 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 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]) @pytest.mark.parametrize("is_private_household", [True, False])
def test_get_one_recipe_from_another_household( def test_get_one_recipe_from_another_household(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool 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) assert recipe["id"] == str(h2_recipe_id)
new_last_made = recipe["lastMade"] new_last_made = recipe["lastMade"]
assert new_last_made == now != old_last_made 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

View File

@ -20,6 +20,7 @@ from recipe_scrapers.plugins import SchemaOrgFillPlugin
from slugify import slugify from slugify import slugify
from mealie.pkgs.safehttp.transport import AsyncSafeTransport 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 import Recipe, RecipeCategory, RecipeSummary, RecipeTag
from mealie.schema.recipe.recipe_category import CategorySave, TagSave from mealie.schema.recipe.recipe_category import CategorySave, TagSave
from mealie.schema.recipe.recipe_notes import RecipeNote 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"} badparams: dict[str, int | str] = {"page": 1, "perPage": -1, "orderBy": "random"}
response = api_client.get(api_routes.recipes, params=badparams, headers=unique_user.token) response = api_client.get(api_routes.recipes, params=badparams, headers=unique_user.token)
assert response.status_code == 422 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

View File

@ -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}" 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): def explore_groups_group_slug_organizers_categories(group_slug):
"""`/api/explore/groups/{group_slug}/organizers/categories`""" """`/api/explore/groups/{group_slug}/organizers/categories`"""
return f"{prefix}/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}" return f"{prefix}/foods/{item_id}"
def groups_households_slug(slug): def groups_households_household_slug(household_slug):
"""`/api/groups/households/{slug}`""" """`/api/groups/households/{household_slug}`"""
return f"{prefix}/groups/households/{slug}" return f"{prefix}/groups/households/{household_slug}"
def groups_labels_item_id(item_id): def groups_labels_item_id(item_id):