Composition #1
This commit is contained in:
parent
3be1027154
commit
61ca41d25a
9 changed files with 225 additions and 173 deletions
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -4843,9 +4843,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001574",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz",
|
||||
"integrity": "sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg==",
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -4860,7 +4860,8 @@
|
|||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/case-sensitive-paths-webpack-plugin": {
|
||||
"version": "2.4.0",
|
||||
|
|
@ -17396,9 +17397,9 @@
|
|||
}
|
||||
},
|
||||
"caniuse-lite": {
|
||||
"version": "1.0.30001574",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz",
|
||||
"integrity": "sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg==",
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"dev": true
|
||||
},
|
||||
"case-sensitive-paths-webpack-plugin": {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@
|
|||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
"node": true,
|
||||
"vue/setup-compiler-macros": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-essential",
|
||||
|
|
|
|||
|
|
@ -55,32 +55,117 @@ Outcome: Stable formatting and consistent linting across contributors.
|
|||
|
||||
### Phase 5 — Tests (Targeted)
|
||||
|
||||
- [x] Add a unit test runner (Vitest)
|
||||
- [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`
|
||||
# Composition API Migration Plan
|
||||
|
||||
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:
|
||||
- Extracted router with names and auth guard
|
||||
- Removed `App.vue` auth redirect
|
||||
- Added `api/http.js`, `api/mappers/mealMapper.js`, `api/meals.js`
|
||||
- Updated `MealPlanPage.vue` to use meals API
|
||||
- Added `api/mappers/recipeMapper.js`, `api/recipes.js`; updated recipes components
|
||||
- Added `api/mappers/shoppingListMapper.js`, `api/shopping.js`; updated shopping components
|
||||
- Added `api/auth.js` and `api/persons.js`; router uses auth API
|
||||
- Added `composables/useAuth.js` and used in `MyShoppingPage.vue`
|
||||
- Added Prettier and EditorConfig
|
||||
- Added `composables/useMeals.js`; migrated meal pages to composable and `<script setup>`
|
||||
- Added `composables/useShopping.js`; migrated shopping pages to composable and `<script setup>`
|
||||
- Added VS Code Volar recommendation (`.vscode/extensions.json`)
|
||||
- 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`
|
||||
## Current status
|
||||
|
||||
Already using `<script setup>`:
|
||||
- Meals: `MealPlanPage.vue`, `EditMealPage.vue`
|
||||
- Shopping: `CurrentShoppingListPage.vue`, `MyShoppingPage.vue`, `PurchasedShoppingListPage.vue`
|
||||
|
||||
Remaining to migrate (Options API or mixed):
|
||||
|
||||
- Core
|
||||
- `App.vue`
|
||||
- `components/AlertToast.vue`
|
||||
- `components/ActionItem.vue`
|
||||
- `components/LoginPage.vue`
|
||||
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<a @click="$emit('click')">
|
||||
<a @click="emit('click')">
|
||||
<h2>{{ title }}</h2>
|
||||
<img :src="image" :alt="name" />
|
||||
<img :src="image" :alt="title" />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ActionItem',
|
||||
props: ['title', 'image'],
|
||||
}
|
||||
<script setup>
|
||||
const emit = defineEmits(['click'])
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
image: { type: String, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -37,19 +37,19 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CompactParsedIngredient',
|
||||
props: ['ingredient'],
|
||||
computed: {
|
||||
searchlink() {
|
||||
return (
|
||||
'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
|
||||
encodeURIComponent(this.ingredient.name)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
ingredient: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const searchlink = computed(() =>
|
||||
props.ingredient?.name
|
||||
? 'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
|
||||
encodeURIComponent(props.ingredient.name)
|
||||
: ''
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -23,40 +23,37 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
ingredient: { type: Object },
|
||||
},
|
||||
events: ['update-ingredient', 'update-product-link'],
|
||||
components: { CompactParsedIngredient },
|
||||
data() {
|
||||
return {
|
||||
ingredientText: this.ingredient?.line ?? '',
|
||||
productLink: this.ingredient.product?.link ?? '',
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
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)
|
||||
},
|
||||
const props = defineProps({
|
||||
ingredient: { type: Object, required: true },
|
||||
})
|
||||
const emit = defineEmits(['update-ingredient', 'update-product-link'])
|
||||
|
||||
const ingredientText = ref(props.ingredient?.line ?? '')
|
||||
const productLink = ref(props.ingredient.product?.link ?? '')
|
||||
|
||||
watch(
|
||||
() => props.ingredient,
|
||||
(newIngredient) => {
|
||||
ingredientText.value = newIngredient?.line ?? ''
|
||||
productLink.value = newIngredient?.product?.link ?? ''
|
||||
},
|
||||
{ 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,14 @@
|
|||
|
||||
<style></style>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ago } from '@/dateformats.js'
|
||||
|
||||
const props = defineProps({
|
||||
meal: { type: Object, required: true },
|
||||
})
|
||||
|
||||
function englishSeperator(index, list) {
|
||||
switch (index) {
|
||||
case list.length - 1:
|
||||
|
|
@ -52,37 +57,23 @@ function englishList(list) {
|
|||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MealCard',
|
||||
props: ['meal'],
|
||||
computed: {
|
||||
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)
|
||||
)
|
||||
const date = computed(() =>
|
||||
props.meal.suggested_date.toLocaleDateString('en-au', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
})
|
||||
)
|
||||
|
||||
if (recipesText && ingredientsText) {
|
||||
return `${recipesText} with ${ingredientsText}`
|
||||
} else if (recipesText || ingredientsText) {
|
||||
return recipesText || ingredientsText
|
||||
} else {
|
||||
return 'Nothing planned'
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
englishSeperator,
|
||||
ago,
|
||||
},
|
||||
}
|
||||
const dayOfWeek = computed(() =>
|
||||
props.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
|
||||
)
|
||||
|
||||
const mealTitle = computed(() => {
|
||||
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}`
|
||||
if (recipesText || ingredientsText) return recipesText || ingredientsText
|
||||
return 'Nothing planned'
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RecipeCard',
|
||||
props: ['recipe'],
|
||||
}
|
||||
<script setup>
|
||||
defineProps({
|
||||
recipe: { type: Object, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,14 @@ label {
|
|||
}
|
||||
</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) => {
|
||||
if (d > 3 && d < 21) return 'th'
|
||||
switch (d % 10) {
|
||||
|
|
@ -92,9 +99,8 @@ const nth = (d) => {
|
|||
}
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
|
||||
}
|
||||
const formatDate = (date) =>
|
||||
`${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
|
||||
|
||||
const getUrl = (meal) => {
|
||||
// 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
|
||||
for (const ingredient of meal.extra_ingredients) {
|
||||
if (ingredient.product) {
|
||||
if (ingredient.product.img_large) {
|
||||
return ingredient.product.img_large
|
||||
}
|
||||
|
||||
if (ingredient.product.img_small) {
|
||||
return ingredient.product.img_small
|
||||
}
|
||||
if (ingredient.product.img_large) return ingredient.product.img_large
|
||||
if (ingredient.product.img_small) return ingredient.product.img_small
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MealSelectionList',
|
||||
props: {
|
||||
meals: Array,
|
||||
checked: Array,
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatDate,
|
||||
getImageStyling(meal) {
|
||||
const imageUrl = getUrl(meal)
|
||||
if (!imageUrl) {
|
||||
return null
|
||||
}
|
||||
function getImageStyling(meal) {
|
||||
const imageUrl = getUrl(meal)
|
||||
if (!imageUrl) return null
|
||||
const opacity = 0.7
|
||||
return {
|
||||
background: `linear-gradient(to bottom, rgba(255, 255, 255, ${opacity}) 0%, rgba(255, 255, 255, ${opacity}) 100%), url('${imageUrl}') center/cover no-repeat`,
|
||||
}
|
||||
}
|
||||
|
||||
const opacity = 0.7
|
||||
return {
|
||||
background: `linear-gradient(to bottom, rgba(255, 255, 255, ${opacity}) 0%, rgba(255, 255, 255, ${opacity}) 100%), url('${imageUrl}') center/cover no-repeat`,
|
||||
}
|
||||
},
|
||||
mealCheckChanged(event) {
|
||||
const mealId = parseInt(event.target.id)
|
||||
const meal = this.meals.find((m) => m.id === mealId)
|
||||
if (event.target.checked) {
|
||||
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
|
||||
},
|
||||
},
|
||||
function mealCheckChanged(event) {
|
||||
const mealId = parseInt(event.target.id)
|
||||
const meal = props.meals.find((m) => m.id === mealId)
|
||||
if (event.target.checked) emit('meal-selected', meal)
|
||||
else emit('meal-unselected', meal)
|
||||
}
|
||||
|
||||
function isChecked(meal) {
|
||||
return props.checked.some((m) => m.id === meal.id)
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue