Composition #1

This commit is contained in:
jableader 2025-10-18 13:39:25 +11:00
parent 3be1027154
commit 61ca41d25a
9 changed files with 225 additions and 173 deletions

15
package-lock.json generated
View file

@ -4843,9 +4843,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001574", "version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg==", "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -4860,7 +4860,8 @@
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
] ],
"license": "CC-BY-4.0"
}, },
"node_modules/case-sensitive-paths-webpack-plugin": { "node_modules/case-sensitive-paths-webpack-plugin": {
"version": "2.4.0", "version": "2.4.0",
@ -17396,9 +17397,9 @@
} }
}, },
"caniuse-lite": { "caniuse-lite": {
"version": "1.0.30001574", "version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg==", "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"dev": true "dev": true
}, },
"case-sensitive-paths-webpack-plugin": { "case-sensitive-paths-webpack-plugin": {

View file

@ -38,7 +38,8 @@
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,
"env": { "env": {
"node": true "node": true,
"vue/setup-compiler-macros": true
}, },
"extends": [ "extends": [
"plugin:vue/vue3-essential", "plugin:vue/vue3-essential",

View file

@ -55,32 +55,117 @@ Outcome: Stable formatting and consistent linting across contributors.
### Phase 5 — Tests (Targeted) ### Phase 5 — Tests (Targeted)
- [x] Add a unit test runner (Vitest) # Composition API Migration Plan
- [x] Test mappers (dates/reference wiring)
- Added tests: `tests/mealMapper.test.js`, `tests/shoppingListMapper.test.js`
- [x] Test `units.js` conversions and totals
- Added tests: `tests/units.test.js`
Outcome: Confidence in refactors and easier onboarding. This document tracks the migration of remaining components to Vue 3 Composition API using `<script setup>`, with clear ordering and acceptance criteria.
## Environment Last updated: 2025-10-18
- Prefer `VUE_APP_API_BASE` for backend base URL (fallback to `/api` for dev). Optionally set a devServer proxy in `vue.config.js`. ## Goals
## Progress Log - Convert all SFCs to Composition API `<script setup>`.
- Remove Options API patterns (`data`, `methods`, `computed`, `watch`, `this.*`).
- Standardize on composables for cross-cutting concerns (auth, alerts, API calls).
- Keep changes incremental and safe with focused PRs and existing tests.
- 2025-10-18: ## Current status
- Extracted router with names and auth guard
- Removed `App.vue` auth redirect Already using `<script setup>`:
- Added `api/http.js`, `api/mappers/mealMapper.js`, `api/meals.js` - Meals: `MealPlanPage.vue`, `EditMealPage.vue`
- Updated `MealPlanPage.vue` to use meals API - Shopping: `CurrentShoppingListPage.vue`, `MyShoppingPage.vue`, `PurchasedShoppingListPage.vue`
- Added `api/mappers/recipeMapper.js`, `api/recipes.js`; updated recipes components
- Added `api/mappers/shoppingListMapper.js`, `api/shopping.js`; updated shopping components Remaining to migrate (Options API or mixed):
- Added `api/auth.js` and `api/persons.js`; router uses auth API
- Added `composables/useAuth.js` and used in `MyShoppingPage.vue` - Core
- Added Prettier and EditorConfig - `App.vue`
- Added `composables/useMeals.js`; migrated meal pages to composable and `<script setup>` - `components/AlertToast.vue`
- Added `composables/useShopping.js`; migrated shopping pages to composable and `<script setup>` - `components/ActionItem.vue`
- Added VS Code Volar recommendation (`.vscode/extensions.json`) - `components/LoginPage.vue`
- Set up Vitest (`vitest.config.js`), added test scripts in `package.json`
- Wrote unit tests for mappers and units: `tests/mealMapper.test.js`, `tests/shoppingListMapper.test.js`, `tests/units.test.js` - Recipes
- `components/recipes/RecipesPage.vue`
- `components/recipes/RecipeSearchBox.vue`
- `components/recipes/RecipeCard.vue`
- `components/recipes/EditRecipePage.vue` (most complex)
- Meals
- `components/meals/MealCard.vue`
- `components/meals/DatePicker.vue`
- `components/meals/PersonList.vue` (mixed Options+setup, unify under `<script setup>`)
- Ingredients
- `components/ingredients/CompactParsedIngredient.vue`
- `components/ingredients/IngredientLine.vue`
- `components/ingredients/EditableIngredientsPanel.vue`
- Shopping
- `components/shopping/MealSelectionList.vue`
- `components/shopping/ShoppingListItem.vue`
## Migration order (batches)
1) Leaf/presentational components (low risk)
- `ActionItem.vue`, `RecipeCard.vue`, `CompactParsedIngredient.vue`
- Patterns: defineProps, no router; replace `props: ['x']` with `defineProps<{...}>` (or JSDoc). Simple emits with `defineEmits`.
2) Simple interactive components
- `IngredientLine.vue`, `MealCard.vue`, `MealSelectionList.vue`, `DatePicker.vue`
- Patterns: replace `data` with `ref/reactive`, computed with `computed`, methods with local functions. Replace `this.$emit` with `emit`.
3) Components with watchers and DOM refs
- `EditableIngredientsPanel.vue`, `PersonList.vue`, `ShoppingListItem.vue`
- Patterns: use `ref` for elements, `onMounted` for subscriptions/layout, `watch` for reactive sources. Ensure timeouts/listeners are cleaned up.
4) Pages and core scaffolding
- `RecipesPage.vue`, `RecipeSearchBox.vue`, `EditRecipePage.vue`, `LoginPage.vue`, `App.vue`, `AlertToast.vue`
- Patterns: replace `this.$router`/`this.$route` with `useRouter`/`useRoute`. Consider a small `useAlert` composable to replace the event bus pattern used by `AlertToast.vue` and current `alert.js`.
## Conventions and helpers
- Routing: `const router = useRouter(); const route = useRoute();`
- Props/Emits:
- `const props = defineProps({ ... })`
- `const emit = defineEmits(['event-name'])`
- State: `const state = reactive({...})` or `const x = ref(initial)`
- Computed/Watch: `const y = computed(() => ...)`; `watch(source, (val, old) => ...)`
- Lifecycle: `onMounted`, `onBeforeUnmount`
- Assets: import statics via `new URL('@/assets/foo.svg', import.meta.url).href` or leave template `require()` where needed (non-blocking).
- Testing: keep current Vitest setup; prefer unit tests for any functional changes.
## Acceptance criteria per component
- The component uses a single `<script setup>` block.
- No `this.*` usage remains; props accessed via `props` or destructured; emits via `emit`.
- Route navigation uses `useRouter`/`useRoute` where applicable.
- All existing functionality and events preserved.
- Lint/test/build pass.
## Tracking checklist
- [ ] Core: `App.vue`
- [ ] Core: `components/AlertToast.vue`
- [ ] Core: `components/ActionItem.vue`
- [ ] Core: `components/LoginPage.vue`
- [ ] Recipes: `components/recipes/RecipesPage.vue`
- [ ] Recipes: `components/recipes/RecipeSearchBox.vue`
- [ ] Recipes: `components/recipes/RecipeCard.vue`
- [ ] Recipes: `components/recipes/EditRecipePage.vue`
- [ ] Meals: `components/meals/MealCard.vue`
- [ ] Meals: `components/meals/DatePicker.vue`
- [ ] Meals: `components/meals/PersonList.vue`
- [ ] Shopping: `components/shopping/MealSelectionList.vue`
- [ ] Shopping: `components/shopping/ShoppingListItem.vue`
## Notes and risks
- `PersonList.vue` aligns a dropdown to an input via DOM measurements; ensure the ref-based approach updates positions correctly on focus/resize.
- `RecipeSearchBox.vue` uses timeouts for debouncing; prefer `watch` with a debounced effect and clean up on unmount.
- `AlertToast.vue` uses a simple event-bus (`alert.js`); consider migrating to a `useAlert` composable with a `ref`-based queue to simplify subscriptions.
## Done (context)
- Routing extracted with auth guard; API layer split; composables for auth/meals/shopping; prettier/husky configured; Vitest tests for mappers and units in place.

View file

@ -1,17 +1,18 @@
<template> <template>
<div class="card"> <div class="card">
<a @click="$emit('click')"> <a @click="emit('click')">
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
<img :src="image" :alt="name" /> <img :src="image" :alt="title" />
</a> </a>
</div> </div>
</template> </template>
<script> <script setup>
export default { const emit = defineEmits(['click'])
name: 'ActionItem', defineProps({
props: ['title', 'image'], title: { type: String, required: true },
} image: { type: String, required: true },
})
</script> </script>
<style scoped> <style scoped>

View file

@ -37,19 +37,19 @@
</div> </div>
</template> </template>
<script> <script setup>
export default { import { computed } from 'vue'
name: 'CompactParsedIngredient',
props: ['ingredient'], const props = defineProps({
computed: { ingredient: { type: Object, required: true },
searchlink() { })
return (
'https://www.woolworths.com.au/shop/search/products?searchTerm=' + const searchlink = computed(() =>
encodeURIComponent(this.ingredient.name) props.ingredient?.name
) ? 'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
}, encodeURIComponent(props.ingredient.name)
}, : ''
} )
</script> </script>
<style scoped> <style scoped>

View file

@ -23,40 +23,37 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, watch } from 'vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue' import CompactParsedIngredient from './CompactParsedIngredient.vue'
export default { const props = defineProps({
props: { ingredient: { type: Object, required: true },
ingredient: { type: Object }, })
}, const emit = defineEmits(['update-ingredient', 'update-product-link'])
events: ['update-ingredient', 'update-product-link'],
components: { CompactParsedIngredient }, const ingredientText = ref(props.ingredient?.line ?? '')
data() { const productLink = ref(props.ingredient.product?.link ?? '')
return {
ingredientText: this.ingredient?.line ?? '', watch(
productLink: this.ingredient.product?.link ?? '', () => props.ingredient,
} (newIngredient) => {
}, ingredientText.value = newIngredient?.line ?? ''
watch: { productLink.value = newIngredient?.product?.link ?? ''
ingredient: {
handler: function (newIngredient) {
this.ingredientText = newIngredient?.line ?? ''
this.productLink = newIngredient.product?.link ?? ''
},
deep: true,
},
},
methods: {
updateIngredient() {
if (this.ingredientText != this.ingredient.line)
this.$emit('update-ingredient', this.ingredient, this.ingredientText)
},
updateProductLink() {
if (this.productLink && this.productLink != this.ingredient.product?.link)
this.$emit('update-product-link', this.ingredient, this.productLink)
},
}, },
{ deep: true }
)
function updateIngredient() {
if (ingredientText.value != props.ingredient.line) {
emit('update-ingredient', props.ingredient, ingredientText.value)
}
}
function updateProductLink() {
if (productLink.value && productLink.value != props.ingredient.product?.link) {
emit('update-product-link', props.ingredient, productLink.value)
}
} }
</script> </script>

View file

@ -25,9 +25,14 @@
<style></style> <style></style>
<script> <script setup>
import { computed } from 'vue'
import { ago } from '@/dateformats.js' import { ago } from '@/dateformats.js'
const props = defineProps({
meal: { type: Object, required: true },
})
function englishSeperator(index, list) { function englishSeperator(index, list) {
switch (index) { switch (index) {
case list.length - 1: case list.length - 1:
@ -52,37 +57,23 @@ function englishList(list) {
} }
} }
export default { const date = computed(() =>
name: 'MealCard', props.meal.suggested_date.toLocaleDateString('en-au', {
props: ['meal'], month: 'numeric',
computed: { day: 'numeric',
date() { })
return this.meal.suggested_date.toLocaleDateString('en-au', { )
month: 'numeric',
day: 'numeric',
})
},
dayOfWeek() {
return this.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
},
mealTitle() {
const recipesText = englishList(this.meal.recipes.map((mealRecipe) => mealRecipe.recipe.name))
const ingredientsText = englishList(
this.meal.extra_ingredients.map((ingredient) => ingredient.name)
)
if (recipesText && ingredientsText) { const dayOfWeek = computed(() =>
return `${recipesText} with ${ingredientsText}` props.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
} else if (recipesText || ingredientsText) { )
return recipesText || ingredientsText
} else { const mealTitle = computed(() => {
return 'Nothing planned' const recipesText = englishList(props.meal.recipes.map((mr) => mr.recipe.name))
} const ingredientsText = englishList(props.meal.extra_ingredients.map((i) => i.name))
},
}, if (recipesText && ingredientsText) return `${recipesText} with ${ingredientsText}`
methods: { if (recipesText || ingredientsText) return recipesText || ingredientsText
englishSeperator, return 'Nothing planned'
ago, })
},
}
</script> </script>

View file

@ -8,11 +8,10 @@
</div> </div>
</template> </template>
<script> <script setup>
export default { defineProps({
name: 'RecipeCard', recipe: { type: Object, required: true },
props: ['recipe'], })
}
</script> </script>
<style scoped> <style scoped>

View file

@ -77,7 +77,14 @@ label {
} }
</style> </style>
<script> <script setup>
const props = defineProps({
meals: { type: Array, required: true },
checked: { type: Array, required: true },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['meal-selected', 'meal-unselected'])
const nth = (d) => { const nth = (d) => {
if (d > 3 && d < 21) return 'th' if (d > 3 && d < 21) return 'th'
switch (d % 10) { switch (d % 10) {
@ -92,9 +99,8 @@ const nth = (d) => {
} }
} }
const formatDate = (date) => { const formatDate = (date) =>
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}` `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
}
const getUrl = (meal) => { const getUrl = (meal) => {
// First non empty value in meal.recipe/image_urls // First non empty value in meal.recipe/image_urls
@ -108,59 +114,30 @@ const getUrl = (meal) => {
// First meal.extra_ingredient with a product with an image // First meal.extra_ingredient with a product with an image
for (const ingredient of meal.extra_ingredients) { for (const ingredient of meal.extra_ingredients) {
if (ingredient.product) { if (ingredient.product) {
if (ingredient.product.img_large) { if (ingredient.product.img_large) return ingredient.product.img_large
return ingredient.product.img_large if (ingredient.product.img_small) return ingredient.product.img_small
}
if (ingredient.product.img_small) {
return ingredient.product.img_small
}
} }
} }
return null return null
} }
export default { function getImageStyling(meal) {
name: 'MealSelectionList', const imageUrl = getUrl(meal)
props: { if (!imageUrl) return null
meals: Array, const opacity = 0.7
checked: Array, return {
disabled: { background: `linear-gradient(to bottom, rgba(255, 255, 255, ${opacity}) 0%, rgba(255, 255, 255, ${opacity}) 100%), url('${imageUrl}') center/cover no-repeat`,
type: Boolean, }
default: false, }
},
},
methods: {
formatDate,
getImageStyling(meal) {
const imageUrl = getUrl(meal)
if (!imageUrl) {
return null
}
const opacity = 0.7 function mealCheckChanged(event) {
return { const mealId = parseInt(event.target.id)
background: `linear-gradient(to bottom, rgba(255, 255, 255, ${opacity}) 0%, rgba(255, 255, 255, ${opacity}) 100%), url('${imageUrl}') center/cover no-repeat`, const meal = props.meals.find((m) => m.id === mealId)
} if (event.target.checked) emit('meal-selected', meal)
}, else emit('meal-unselected', meal)
mealCheckChanged(event) { }
const mealId = parseInt(event.target.id)
const meal = this.meals.find((m) => m.id === mealId) function isChecked(meal) {
if (event.target.checked) { return props.checked.some((m) => m.id === meal.id)
this.$emit('meal-selected', meal)
} else {
this.$emit('meal-unselected', meal)
}
},
isChecked(meal) {
for (const checkedMeal of this.checked) {
if (checkedMeal.id === meal.id) {
return true
}
}
return false
},
},
} }
</script> </script>