mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: Improve shopping list label sections (#6345)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
		
							
								
								
									
										17
									
								
								frontend/components/global/BaseExpansionPanels.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/components/global/BaseExpansionPanels.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | <template> | ||||||
|  |   <v-expansion-panels v-model="open"> | ||||||
|  |     <slot /> | ||||||
|  |   </v-expansion-panels> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | interface Props { | ||||||
|  |   startOpen?: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const props = withDefaults(defineProps<Props>(), { | ||||||
|  |   startOpen: false, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const open = ref(props.startOpen ? [0] : []); | ||||||
|  | </script> | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| import { useToggle } from "@vueuse/core"; | import { useToggle } from "@vueuse/core"; | ||||||
| import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household"; | import type { ShoppingListOut } from "~/lib/api/types/household"; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Composable for managing shopping list label state and operations |  * Composable for managing shopping list label state and operations | ||||||
| @@ -36,14 +36,24 @@ export function useShoppingListLabels(shoppingList: Ref<ShoppingListOut | null>) | |||||||
|     ); |     ); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   const labelColorByName = computed(() => { | ||||||
|  |     const map: Record<string, string | undefined> = {}; | ||||||
|  |     shoppingList.value?.listItems?.forEach((item) => { | ||||||
|  |       if (!item.label) return; | ||||||
|  |       const labelName = item.label?.name || t("shopping-list.no-label"); | ||||||
|  |       map[labelName] = item.label.color; | ||||||
|  |     }); | ||||||
|  |     return map; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   watch(labelNames, initializeLabelOpenStates, { immediate: true }); |   watch(labelNames, initializeLabelOpenStates, { immediate: true }); | ||||||
|  |  | ||||||
|   function toggleShowLabel(key: string) { |   function toggleShowLabel(key: string) { | ||||||
|     labelOpenState.value[key] = !labelOpenState.value[key]; |     labelOpenState.value[key] = !labelOpenState.value[key]; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   function getLabelColor(item: ShoppingListItemOut | null) { |   function getLabelColor(label: string) { | ||||||
|     return item?.label?.color; |     return labelColorByName.value[label]; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const presentLabels = computed(() => { |   const presentLabels = computed(() => { | ||||||
|   | |||||||
| @@ -36,6 +36,34 @@ | |||||||
|       </v-card-text> |       </v-card-text> | ||||||
|     </BaseDialog> |     </BaseDialog> | ||||||
|  |  | ||||||
|  |     <!-- Reorder Labels --> | ||||||
|  |     <BaseDialog | ||||||
|  |       v-model="reorderLabelsDialog" | ||||||
|  |       :icon="$globals.icons.tagArrowUp" | ||||||
|  |       :title="$t('shopping-list.reorder-labels')" | ||||||
|  |       :submit-icon="$globals.icons.save" | ||||||
|  |       :submit-text="$t('general.save')" | ||||||
|  |       can-submit | ||||||
|  |       @submit="saveLabelOrder" | ||||||
|  |       @close="cancelLabelOrder" | ||||||
|  |     > | ||||||
|  |       <v-card height="fit-content" max-height="70vh" style="overflow-y: auto;"> | ||||||
|  |         <VueDraggable | ||||||
|  |           v-if="localLabels" | ||||||
|  |           v-model="localLabels" | ||||||
|  |           handle=".handle" | ||||||
|  |           :delay="250" | ||||||
|  |           :delay-on-touch-only="true" | ||||||
|  |           class="my-2" | ||||||
|  |           @update:model-value="updateLabelOrder" | ||||||
|  |         > | ||||||
|  |           <div v-for="(labelSetting, index) in localLabels" :key="labelSetting.id"> | ||||||
|  |             <MultiPurposeLabelSection v-model="localLabels[index]" use-color /> | ||||||
|  |           </div> | ||||||
|  |         </VueDraggable> | ||||||
|  |       </v-card> | ||||||
|  |     </BaseDialog> | ||||||
|  |  | ||||||
|     <BasePageTitle divider> |     <BasePageTitle divider> | ||||||
|       <template #header> |       <template #header> | ||||||
|         <v-container class="px-0"> |         <v-container class="px-0"> | ||||||
| @@ -127,10 +155,7 @@ | |||||||
|     /> |     /> | ||||||
|  |  | ||||||
|     <!-- Viewer --> |     <!-- Viewer --> | ||||||
|     <section |     <section v-if="!edit" class="py-2 d-flex flex-column ga-4"> | ||||||
|       v-if="!edit" |  | ||||||
|       class="py-2" |  | ||||||
|     > |  | ||||||
|       <!-- Create Item --> |       <!-- Create Item --> | ||||||
|       <div v-if="createEditorOpen"> |       <div v-if="createEditorOpen"> | ||||||
|         <ShoppingListItemEditor |         <ShoppingListItemEditor | ||||||
| @@ -154,27 +179,15 @@ | |||||||
|         </BaseButton> |         </BaseButton> | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|       <div |       <BaseExpansionPanels v-for="(value, key) in itemsByLabel" :key="key" :v-model="0" start-open> | ||||||
|         v-for="(value, key) in itemsByLabel" |         <v-expansion-panel class="shopping-list-section"> | ||||||
|         :key="key" |           <v-expansion-panel-title | ||||||
|         class="pb-4" |             :color="getLabelColor(key)" | ||||||
|  |             class="body-1 font-weight-bold section-title" | ||||||
|           > |           > | ||||||
|         <v-btn |  | ||||||
|           :color="getLabelColor(value[0]) ? getLabelColor(value[0]) : '#959595'" |  | ||||||
|           :style="{ |  | ||||||
|             'color': getTextColor(getLabelColor(value[0])), |  | ||||||
|             'letter-spacing': 'normal', |  | ||||||
|           }" |  | ||||||
|           @click="toggleShowLabel(key.toString())" |  | ||||||
|         > |  | ||||||
|           <v-icon> |  | ||||||
|             {{ labelOpenState[key] ? $globals.icons.chevronDown : $globals.icons.chevronRight }} |  | ||||||
|           </v-icon> |  | ||||||
|             {{ key }} |             {{ key }} | ||||||
|         </v-btn> |           </v-expansion-panel-title> | ||||||
|         <v-divider /> |           <v-expansion-panel-text eager> | ||||||
|         <v-expand-transition> |  | ||||||
|           <div v-if="labelOpenState[key]"> |  | ||||||
|             <VueDraggable |             <VueDraggable | ||||||
|               :model-value="value" |               :model-value="value" | ||||||
|               handle=".handle" |               handle=".handle" | ||||||
| @@ -184,13 +197,11 @@ | |||||||
|               @end="loadingCounter -= 1" |               @end="loadingCounter -= 1" | ||||||
|               @update:model-value="updateIndexUncheckedByLabel(key.toString(), $event)" |               @update:model-value="updateIndexUncheckedByLabel(key.toString(), $event)" | ||||||
|             > |             > | ||||||
|               <v-lazy |               <ShoppingListItem | ||||||
|                 v-for="(item, index) in value" |                 v-for="(item, index) in value" | ||||||
|                 :key="item.id" |                 :key="item.id" | ||||||
|                 class="ml-2 my-2" |  | ||||||
|               > |  | ||||||
|                 <ShoppingListItem |  | ||||||
|                 v-model="value[index]" |                 v-model="value[index]" | ||||||
|  |                 class="ml-2 my-2 w-auto" | ||||||
|                 :labels="allLabels || []" |                 :labels="allLabels || []" | ||||||
|                 :units="allUnits || []" |                 :units="allUnits || []" | ||||||
|                 :foods="allFoods || []" |                 :foods="allFoods || []" | ||||||
| @@ -199,67 +210,19 @@ | |||||||
|                 @save="saveListItem" |                 @save="saveListItem" | ||||||
|                 @delete="deleteListItem(item)" |                 @delete="deleteListItem(item)" | ||||||
|               /> |               /> | ||||||
|               </v-lazy> |  | ||||||
|             </VueDraggable> |             </VueDraggable> | ||||||
|           </div> |           </v-expansion-panel-text> | ||||||
|         </v-expand-transition> |         </v-expansion-panel> | ||||||
|       </div> |       </BaseExpansionPanels> | ||||||
|  |  | ||||||
|       <!-- Reorder Labels --> |  | ||||||
|       <BaseDialog |  | ||||||
|         v-model="reorderLabelsDialog" |  | ||||||
|         :icon="$globals.icons.tagArrowUp" |  | ||||||
|         :title="$t('shopping-list.reorder-labels')" |  | ||||||
|         :submit-icon="$globals.icons.save" |  | ||||||
|         :submit-text="$t('general.save')" |  | ||||||
|         can-submit |  | ||||||
|         @submit="saveLabelOrder" |  | ||||||
|         @close="cancelLabelOrder" |  | ||||||
|       > |  | ||||||
|         <v-card |  | ||||||
|           height="fit-content" |  | ||||||
|           max-height="70vh" |  | ||||||
|           style="overflow-y: auto;" |  | ||||||
|         > |  | ||||||
|           <VueDraggable |  | ||||||
|             v-if="localLabels" |  | ||||||
|             v-model="localLabels" |  | ||||||
|             handle=".handle" |  | ||||||
|             :delay="250" |  | ||||||
|             :delay-on-touch-only="true" |  | ||||||
|             class="my-2" |  | ||||||
|             @update:model-value="updateLabelOrder" |  | ||||||
|           > |  | ||||||
|             <div |  | ||||||
|               v-for="(labelSetting, index) in localLabels" |  | ||||||
|               :key="labelSetting.id" |  | ||||||
|             > |  | ||||||
|               <MultiPurposeLabelSection |  | ||||||
|                 v-model="localLabels[index]" |  | ||||||
|                 use-color |  | ||||||
|               /> |  | ||||||
|             </div> |  | ||||||
|           </VueDraggable> |  | ||||||
|         </v-card> |  | ||||||
|       </BaseDialog> |  | ||||||
|  |  | ||||||
|       <!-- Checked Items --> |       <!-- Checked Items --> | ||||||
|       <div |       <v-expansion-panels flat> | ||||||
|         v-if="listItems.checked && listItems.checked.length > 0" |         <v-expansion-panel v-if="listItems.checked && listItems.checked.length > 0"> | ||||||
|         class="mt-6" |           <v-expansion-panel-title class="border-solid border-thin py-1"> | ||||||
|       > |             <div class="d-flex align-center flex-0-1-100"> | ||||||
|         <div class="d-flex"> |               <div class="flex-1-0"> | ||||||
|           <div class="flex-grow-1"> |  | ||||||
|             <button @click="toggleShowChecked()"> |  | ||||||
|               <span> |  | ||||||
|                 <v-icon> |  | ||||||
|                   {{ showChecked ? $globals.icons.chevronDown : $globals.icons.chevronRight }} |  | ||||||
|                 </v-icon> |  | ||||||
|               </span> |  | ||||||
|                 {{ $t('shopping-list.items-checked-count', listItems.checked ? listItems.checked.length : 0) }} |                 {{ $t('shopping-list.items-checked-count', listItems.checked ? listItems.checked.length : 0) }} | ||||||
|             </button> |  | ||||||
|               </div> |               </div> | ||||||
|           <div class="justify-end mt-n2"> |               <div class="justify-end"> | ||||||
|                 <BaseButtonGroup |                 <BaseButtonGroup | ||||||
|                   :buttons="[ |                   :buttons="[ | ||||||
|                     { |                     { | ||||||
| @@ -278,13 +241,9 @@ | |||||||
|                 /> |                 /> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|         <v-divider class="my-4" /> |           </v-expansion-panel-title> | ||||||
|         <v-expand-transition> |           <v-expansion-panel-text eager> | ||||||
|           <div v-if="showChecked"> |             <div v-for="(item, idx) in listItems.checked" :key="item.id"> | ||||||
|             <div |  | ||||||
|               v-for="(item, idx) in listItems.checked" |  | ||||||
|               :key="item.id" |  | ||||||
|             > |  | ||||||
|               <ShoppingListItem |               <ShoppingListItem | ||||||
|                 v-model="listItems.checked[idx]" |                 v-model="listItems.checked[idx]" | ||||||
|                 class="strike-through-note" |                 class="strike-through-note" | ||||||
| @@ -296,14 +255,15 @@ | |||||||
|                 @delete="deleteListItem(item)" |                 @delete="deleteListItem(item)" | ||||||
|               /> |               /> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </v-expansion-panel-text> | ||||||
|         </v-expand-transition> |         </v-expansion-panel> | ||||||
|       </div> |       </v-expansion-panels> | ||||||
|     </section> |     </section> | ||||||
|  |  | ||||||
|     <!-- Recipe References --> |     <!-- Recipe References --> | ||||||
|     <v-lazy |     <v-lazy | ||||||
|       v-if="shoppingList.recipeReferences && shoppingList.recipeReferences.length > 0" |       v-if="shoppingList.recipeReferences && shoppingList.recipeReferences.length > 0" | ||||||
|  |       class="mt-6" | ||||||
|     > |     > | ||||||
|       <section> |       <section> | ||||||
|         <div> |         <div> | ||||||
| @@ -316,7 +276,7 @@ | |||||||
|             ? shoppingList.recipeReferences.length |             ? shoppingList.recipeReferences.length | ||||||
|             : 0) }} |             : 0) }} | ||||||
|         </div> |         </div> | ||||||
|         <v-divider class="my-4" /> |         <v-divider /> | ||||||
|         <RecipeList |         <RecipeList | ||||||
|           :recipes="recipeList" |           :recipes="recipeList" | ||||||
|           show-description |           show-description | ||||||
| @@ -367,14 +327,14 @@ | |||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { VueDraggable } from "vue-draggable-plus"; | import { VueDraggable } from "vue-draggable-plus"; | ||||||
|  | import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; | ||||||
| import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"; | import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"; | ||||||
| import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; | import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; | ||||||
| import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; |  | ||||||
| import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; | import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; | ||||||
| import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store"; |  | ||||||
| import { useShoppingListPreferences } from "~/composables/use-users/preferences"; |  | ||||||
| import { getTextColor } from "~/composables/use-text-color"; |  | ||||||
| import { useShoppingListPage } from "~/composables/shopping-list-page/use-shopping-list-page"; | import { useShoppingListPage } from "~/composables/shopping-list-page/use-shopping-list-page"; | ||||||
|  | import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store"; | ||||||
|  | import { getTextColor } from "~/composables/use-text-color"; | ||||||
|  | import { useShoppingListPreferences } from "~/composables/use-users/preferences"; | ||||||
|  |  | ||||||
| export default defineNuxtComponent({ | export default defineNuxtComponent({ | ||||||
|   components: { |   components: { | ||||||
| @@ -417,8 +377,19 @@ export default defineNuxtComponent({ | |||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style> | ||||||
| .number-input-container { | .number-input-container { | ||||||
|   max-width: 50px; |   max-width: 50px; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .shopping-list-section { | ||||||
|  |   .section-title { | ||||||
|  |     font-size: 1rem; | ||||||
|  |     min-height: 48px !important; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .v-expansion-panel-text__wrapper { | ||||||
|  |     padding: 0; | ||||||
|  |   } | ||||||
|  | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								frontend/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								frontend/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -13,6 +13,7 @@ import type BaseButtonGroup from "@/components/global/BaseButtonGroup.vue"; | |||||||
| import type BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue"; | import type BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue"; | ||||||
| import type BaseDialog from "@/components/global/BaseDialog.vue"; | import type BaseDialog from "@/components/global/BaseDialog.vue"; | ||||||
| import type BaseDivider from "@/components/global/BaseDivider.vue"; | import type BaseDivider from "@/components/global/BaseDivider.vue"; | ||||||
|  | import type BaseExpansionPanels from "@/components/global/BaseExpansionPanels.vue"; | ||||||
| import type BaseOverflowButton from "@/components/global/BaseOverflowButton.vue"; | import type BaseOverflowButton from "@/components/global/BaseOverflowButton.vue"; | ||||||
| import type BasePageTitle from "@/components/global/BasePageTitle.vue"; | import type BasePageTitle from "@/components/global/BasePageTitle.vue"; | ||||||
| import type BaseStatCard from "@/components/global/BaseStatCard.vue"; | import type BaseStatCard from "@/components/global/BaseStatCard.vue"; | ||||||
| @@ -54,6 +55,7 @@ declare module "vue" { | |||||||
|     BaseCardSectionTitle: typeof BaseCardSectionTitle; |     BaseCardSectionTitle: typeof BaseCardSectionTitle; | ||||||
|     BaseDialog: typeof BaseDialog; |     BaseDialog: typeof BaseDialog; | ||||||
|     BaseDivider: typeof BaseDivider; |     BaseDivider: typeof BaseDivider; | ||||||
|  |     BaseExpansionPanels: typeof BaseExpansionPanels; | ||||||
|     BaseOverflowButton: typeof BaseOverflowButton; |     BaseOverflowButton: typeof BaseOverflowButton; | ||||||
|     BasePageTitle: typeof BasePageTitle; |     BasePageTitle: typeof BasePageTitle; | ||||||
|     BaseStatCard: typeof BaseStatCard; |     BaseStatCard: typeof BaseStatCard; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user