Refactor Shopping List API (#2021)

* tidied up shopping list item models
redefined recipe refs and updated models
added calculated display attribute to unify shopping list item rendering
added validation to use a food's label if an item's label is null

* fixed schema reference

* refactored shopping list item service
route all operations through one central method to account for edgecases
return item collections for all operations to account for merging
consolidate recipe items before sending them to the shopping list

* made fractions prettier

* replaced redundant display text util

* fixed edgecase for zero quantity items on a recipe

* fix for pre-merging recipe ingredients

* fixed edgecase for merging create_items together

* fixed bug with merged updated items creating dupes

* added test for self-removing recipe ref

* update items are now merged w/ existing items

* refactored service to make it easier to read

* added a lot of tests

* made it so checked items are never merged

* fixed bug with dragging + re-ordering

* fix for postgres cascade issue

* added prevalidator to recipe ref to avoid db error
This commit is contained in:
Michael Genson 2023-01-28 18:45:02 -06:00 committed by GitHub
commit 617cc1fdfb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1398 additions and 576 deletions

View file

@ -10,7 +10,7 @@
>
<template #label>
<div :class="listItem.checked ? 'strike-through' : ''">
{{ displayText }}
{{ listItem.display }}
</div>
</template>
</v-checkbox>
@ -55,10 +55,9 @@
import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api";
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
import { ShoppingListItemCreate } from "~/lib/api/types/group";
import { ShoppingListItemOut } from "~/lib/api/types/group";
import { MultiPurposeLabelOut } from "~/lib/api/types/labels";
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
import { getDisplayText } from "~/composables/use-display-text";
import { MultiPurposeLabelSummary } from "~/lib/api/types/user";
interface actions {
@ -70,7 +69,7 @@ export default defineComponent({
components: { ShoppingListItemEditor, MultiPurposeLabel },
props: {
value: {
type: Object as () => ShoppingListItemCreate,
type: Object as () => ShoppingListItemOut,
required: true,
},
labels: {
@ -147,10 +146,6 @@ export default defineComponent({
});
});
const displayText = computed(() =>
getDisplayText(listItem.value.note, listItem.value.quantity, listItem.value.food, listItem.value.unit)
);
/**
* Gets the label for the shopping list item. Either the label assign to the item
* or the label of the food applied.
@ -170,7 +165,6 @@ export default defineComponent({
});
return {
displayText,
updatedLabels,
save,
contextHandler,

View file

@ -1,39 +0,0 @@
/**
* use-display-text module contains helpful utility functions to compute the display text when provided
* with the food, units, quantity, and notes.
*/
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
export function getDisplayText(
notes = "",
quantity: number | null = null,
food: IngredientFood | null = null,
unit: IngredientUnit | null = null
): string {
// Fallback to note only if no food or unit is provided
if (food === null && unit === null) {
return `${quantity || ""} ${notes}`.trim();
}
// Otherwise build the display text
let displayText = "";
if (quantity) {
displayText += quantity;
}
if (unit) {
displayText += ` ${unit.name}`;
}
if (food) {
displayText += ` ${food.name}`;
}
if (notes) {
displayText += ` ${notes}`;
}
return displayText.trim();
}

View file

@ -245,6 +245,9 @@ export interface SetPermissions {
canInvite?: boolean;
canOrganize?: boolean;
}
export interface ShoppingListAddRecipeParams {
recipeIncrementQuantity?: number;
}
export interface ShoppingListCreate {
name?: string;
extras?: {
@ -253,6 +256,20 @@ export interface ShoppingListCreate {
createdAt?: string;
updateAt?: string;
}
export interface ShoppingListItemBase {
shoppingListId: string;
checked?: boolean;
position?: number;
isFood?: boolean;
note?: string;
quantity?: number;
foodId?: string;
labelId?: string;
unitId?: string;
extras?: {
[k: string]: unknown;
};
}
export interface ShoppingListItemCreate {
shoppingListId: string;
checked?: boolean;
@ -260,28 +277,38 @@ export interface ShoppingListItemCreate {
isFood?: boolean;
note?: string;
quantity?: number;
unitId?: string;
unit?: IngredientUnit;
foodId?: string;
food?: IngredientFood;
labelId?: string;
recipeReferences?: ShoppingListItemRecipeRef[];
unitId?: string;
extras?: {
[k: string]: unknown;
};
createdAt?: string;
updateAt?: string;
recipeReferences?: ShoppingListItemRecipeRefCreate[];
}
export interface IngredientUnit {
name: string;
description?: string;
export interface ShoppingListItemRecipeRefCreate {
recipeId: string;
recipeQuantity?: number;
recipeScale?: number;
}
export interface ShoppingListItemOut {
shoppingListId: string;
checked?: boolean;
position?: number;
isFood?: boolean;
note?: string;
quantity?: number;
foodId?: string;
labelId?: string;
unitId?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
display?: string;
food?: IngredientFood;
label?: MultiPurposeLabelSummary;
unit?: IngredientUnit;
recipeReferences?: ShoppingListItemRecipeRefOut[];
createdAt?: string;
updateAt?: string;
}
@ -303,34 +330,30 @@ export interface MultiPurposeLabelSummary {
groupId: string;
id: string;
}
export interface ShoppingListItemRecipeRef {
recipeId: string;
recipeQuantity?: number;
}
export interface ShoppingListItemOut {
shoppingListId: string;
checked?: boolean;
position?: number;
isFood?: boolean;
note?: string;
quantity?: number;
unitId?: string;
unit?: IngredientUnit;
foodId?: string;
food?: IngredientFood;
labelId?: string;
recipeReferences?: (ShoppingListItemRecipeRef | ShoppingListItemRecipeRefOut)[];
export interface IngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
id: string;
label?: MultiPurposeLabelSummary;
}
export interface ShoppingListItemRecipeRefOut {
recipeId: string;
recipeQuantity?: number;
recipeScale?: number;
id: string;
shoppingListItemId: string;
}
export interface ShoppingListItemRecipeRefUpdate {
recipeId: string;
recipeQuantity?: number;
recipeScale?: number;
id: string;
shoppingListItemId: string;
}
@ -341,19 +364,41 @@ export interface ShoppingListItemUpdate {
isFood?: boolean;
note?: string;
quantity?: number;
unitId?: string;
unit?: IngredientUnit;
foodId?: string;
food?: IngredientFood;
labelId?: string;
recipeReferences?: ShoppingListItemRecipeRef[];
unitId?: string;
extras?: {
[k: string]: unknown;
};
createdAt?: string;
updateAt?: string;
recipeReferences?: (ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate)[];
}
/**
* Only used for bulk update operations where the shopping list item id isn't already supplied
*/
export interface ShoppingListItemUpdateBulk {
shoppingListId: string;
checked?: boolean;
position?: number;
isFood?: boolean;
note?: string;
quantity?: number;
foodId?: string;
labelId?: string;
unitId?: string;
extras?: {
[k: string]: unknown;
};
recipeReferences?: (ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate)[];
id: string;
}
/**
* Container for bulk shopping list item changes
*/
export interface ShoppingListItemsCollectionOut {
createdItems?: ShoppingListItemOut[];
updatedItems?: ShoppingListItemOut[];
deletedItems?: ShoppingListItemOut[];
}
export interface ShoppingListOut {
name?: string;
extras?: {
@ -442,6 +487,9 @@ export interface CreateIngredientFood {
};
labelId?: string;
}
export interface ShoppingListRemoveRecipeParams {
recipeDecrementQuantity?: number;
}
export interface ShoppingListSave {
name?: string;
extras?: {

View file

@ -69,6 +69,7 @@ export interface LongLiveTokenOut {
token: string;
name: string;
id: number;
createdAt?: string;
}
export interface ReadGroupPreferences {
privateGroup?: boolean;

View file

@ -4,7 +4,7 @@ import {
ShoppingListCreate,
ShoppingListItemCreate,
ShoppingListItemOut,
ShoppingListItemUpdate,
ShoppingListItemUpdateBulk,
ShoppingListOut,
ShoppingListUpdate,
} from "~/lib/api/types/group";
@ -37,7 +37,7 @@ export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListCreate, ShoppingLi
export class ShoppingListItemsApi extends BaseCRUDAPI<
ShoppingListItemCreate,
ShoppingListItemOut,
ShoppingListItemUpdate
ShoppingListItemUpdateBulk
> {
baseRoute = routes.shoppingListItems;
itemRoute = routes.shoppingListItemsId;

View file

@ -10,7 +10,7 @@
<!-- Viewer -->
<section v-if="!edit" class="py-2">
<div v-if="!byLabel">
<draggable :value="shoppingList.listItems" handle=".handle" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndex">
<draggable :value="listItems.unchecked" handle=".handle" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndexUnchecked">
<v-lazy v-for="(item, index) in listItems.unchecked" :key="item.id">
<ShoppingListItem
v-model="listItems.unchecked[index]"
@ -131,7 +131,7 @@
<ShoppingListItem
v-model="listItems.checked[idx]"
class="strike-through-note"
:labels="allLabels"
:labels="allLabels || []"
:units="allUnits || []"
:foods="allFoods || []"
@checked="saveListItem"
@ -196,7 +196,6 @@ import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.
import { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/group";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { getDisplayText } from "~/composables/use-display-text";
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
type CopyTypes = "plain" | "markdown";
@ -313,7 +312,7 @@ export default defineComponent({
return;
}
const text = items.map((itm) => getDisplayText(itm.note, itm.quantity, itm.food, itm.unit));
const text: string[] = items.map((itm) => itm.display || "");
switch (copyType) {
case "markdown":
@ -514,7 +513,7 @@ export default defineComponent({
if (item.checked && shoppingList.value.listItems) {
const lst = shoppingList.value.listItems.filter((itm) => itm.id !== item.id);
lst.push(item);
updateIndex(lst);
updateListItems();
}
const { data } = await userApi.shopping.items.updateOne(item.id, item);
@ -553,9 +552,9 @@ export default defineComponent({
isFood: false,
quantity: 1,
note: "",
unit: undefined,
food: undefined,
labelId: undefined,
unitId: undefined,
foodId: undefined,
};
}
@ -578,9 +577,10 @@ export default defineComponent({
}
}
function updateIndex(data: ShoppingListItemOut[]) {
function updateIndexUnchecked(uncheckedItems: ShoppingListItemOut[]) {
if (shoppingList.value?.listItems) {
shoppingList.value.listItems = data;
// move the new unchecked items in front of the checked items
shoppingList.value.listItems = uncheckedItems.concat(listItems.value.checked);
}
updateListItems();
@ -646,7 +646,7 @@ export default defineComponent({
sortByLabels,
toggleShowChecked,
uncheckAll,
updateIndex,
updateIndexUnchecked,
allUnits,
allFoods,
};