ingredient debounce
This commit is contained in:
parent
9f63c8b091
commit
f35739e732
4 changed files with 109 additions and 15 deletions
|
|
@ -303,3 +303,11 @@ Google OAuth is planned next.
|
||||||
- Remove now-unused legacy stubs from SDK/composable in a cleanup pass (non-functional, safe to delete).
|
- Remove now-unused legacy stubs from SDK/composable in a cleanup pass (non-functional, safe to delete).
|
||||||
- Remove the legacy username login shim (`login(username: string)`) and any fallback UI; standardize on email/password (and Google) only.
|
- Remove the legacy username login shim (`login(username: string)`) and any fallback UI; standardize on email/password (and Google) only.
|
||||||
- Rollout flag: default `VUE_APP_MULTITENANT_ENABLED` to true across environments and plan removal of legacy flat routes and related tests once stable.
|
- Rollout flag: default `VUE_APP_MULTITENANT_ENABLED` to true across environments and plan removal of legacy flat routes and related tests once stable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ingredients & Sides UX (Updated Nov 2, 2025)
|
||||||
|
|
||||||
|
- Controlled inputs: The parent meal edit page owns the latest extra ingredient line text immediately as the user types. Preview parsing still occurs on blur/Enter.
|
||||||
|
- Partial parse on Save: Only lines edited since the last parse are sent to `GET /ingredients/parse?lines=...`. Existing parsed lines are not re-parsed. Empty rows are dropped and a final guard filters zero-quantity items.
|
||||||
|
- Outcome: You can click Save mid-typing without losing changes or hitting validation errors, and we avoid unnecessary parsing work.
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
<p class="ingredient-line">
|
<p class="ingredient-line">
|
||||||
<ingredient-line
|
<ingredient-line
|
||||||
:ingredient="ingredient"
|
:ingredient="ingredient"
|
||||||
|
@update-line="(ing, line) => emit('on-update-line', ing, line)"
|
||||||
@update-ingredient="updateIngredient"
|
@update-ingredient="updateIngredient"
|
||||||
@update-product-link="updateProduct"
|
@update-product-link="updateProduct"
|
||||||
/>
|
/>
|
||||||
|
|
@ -73,11 +74,13 @@ const emit = defineEmits<{
|
||||||
(e: 'on-add'): void
|
(e: 'on-add'): void
|
||||||
(e: 'on-delete', ingredient: Ingredient): void
|
(e: 'on-delete', ingredient: Ingredient): void
|
||||||
(e: 'on-update-ingredient', ingredient: Ingredient, newIngredient: Ingredient): void
|
(e: 'on-update-ingredient', ingredient: Ingredient, newIngredient: Ingredient): void
|
||||||
|
(e: 'on-update-line', ingredient: Ingredient, newLine: string): void
|
||||||
(e: 'on-editing', isEditing: boolean): void
|
(e: 'on-editing', isEditing: boolean): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const editing = ref(props.editOnly ?? false)
|
const editing = ref(props.editOnly ?? false)
|
||||||
|
|
||||||
|
|
||||||
async function updateProduct(): Promise<void> {
|
async function updateProduct(): Promise<void> {
|
||||||
// parseProduct is not available in v2 API; ignore for now
|
// parseProduct is not available in v2 API; ignore for now
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +95,7 @@ function toggleEditing() {
|
||||||
editing.value = !editing.value
|
editing.value = !editing.value
|
||||||
emit('on-editing', editing.value)
|
emit('on-editing', editing.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<input
|
<input
|
||||||
v-model="ingredientText"
|
v-model="ingredientText"
|
||||||
placeholder="Enter an ingredient"
|
placeholder="Enter an ingredient"
|
||||||
|
@input="onInput"
|
||||||
@keyup.enter="updateIngredient"
|
@keyup.enter="updateIngredient"
|
||||||
@blur="updateIngredient"
|
@blur="updateIngredient"
|
||||||
>
|
>
|
||||||
|
|
@ -30,6 +31,7 @@ import type { Ingredient } from '@/domain/types'
|
||||||
|
|
||||||
const props = defineProps<{ ingredient: Ingredient }>()
|
const props = defineProps<{ ingredient: Ingredient }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
(e: 'update-line', ingredient: Ingredient, newLine: string): void
|
||||||
(e: 'update-ingredient', ingredient: Ingredient, newLine: string): void
|
(e: 'update-ingredient', ingredient: Ingredient, newLine: string): void
|
||||||
(e: 'update-product-link', ingredient: Ingredient, link: string): void
|
(e: 'update-product-link', ingredient: Ingredient, link: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -57,6 +59,20 @@ function updateProductLink() {
|
||||||
emit('update-product-link', props.ingredient, productLink.value)
|
emit('update-product-link', props.ingredient, productLink.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit raw line changes so parent stays in sync even before parse
|
||||||
|
function isHtmlInput(el: EventTarget | null): el is HTMLInputElement {
|
||||||
|
return typeof HTMLElement !== 'undefined' && el instanceof HTMLInputElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(e: Event) {
|
||||||
|
let val = ingredientText.value
|
||||||
|
const t = e.target
|
||||||
|
if (isHtmlInput(t)) {
|
||||||
|
val = t.value
|
||||||
|
}
|
||||||
|
if (val !== props.ingredient.line) emit('update-line', props.ingredient, val)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@
|
||||||
@on-add="addIngredient"
|
@on-add="addIngredient"
|
||||||
@on-delete="deleteIngredient"
|
@on-delete="deleteIngredient"
|
||||||
@on-update-ingredient="updateIngredient"
|
@on-update-ingredient="updateIngredient"
|
||||||
|
@on-update-line="updateIngredientLine"
|
||||||
@on-editing="onEditAdditionalIngredients"
|
@on-editing="onEditAdditionalIngredients"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -152,8 +153,13 @@ const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { show: showAlert } = useAlert()
|
const { show: showAlert } = useAlert()
|
||||||
|
|
||||||
|
// No child refs; parent maintains source of truth for lines
|
||||||
|
|
||||||
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
|
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
|
||||||
|
|
||||||
|
// Track which ingredient objects have unparsed edits so we only parse what changed
|
||||||
|
const dirtyLines = new Map<Ingredient, string>()
|
||||||
|
|
||||||
const meal = reactive<Meal>({
|
const meal = reactive<Meal>({
|
||||||
id: -1,
|
id: -1,
|
||||||
suggestedDate: new Date(),
|
suggestedDate: new Date(),
|
||||||
|
|
@ -207,6 +213,23 @@ function deleteIngredient(ingredient: Ingredient) {
|
||||||
|
|
||||||
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
|
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
|
||||||
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
|
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
|
||||||
|
// Clear dirty status for this row (was parsed and replaced)
|
||||||
|
if (dirtyLines.has(ingredient)) dirtyLines.delete(ingredient)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIngredientLine(ingredient: Ingredient, newLine: string) {
|
||||||
|
// Update the raw line immediately so Save has the latest text
|
||||||
|
const idx = meal.extraIngredients.indexOf(ingredient)
|
||||||
|
if (idx >= 0) {
|
||||||
|
// mutate in place to preserve object identity (used as dirtyLines key)
|
||||||
|
const target = meal.extraIngredients[idx]
|
||||||
|
if (target) target.line = newLine
|
||||||
|
} else {
|
||||||
|
// fallback (shouldn't generally happen)
|
||||||
|
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? { ...i, line: newLine } : i))
|
||||||
|
}
|
||||||
|
// Mark as dirty to parse later (on save) if needed
|
||||||
|
dirtyLines.set(ingredient, newLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePerson(list: PeopleKey, person: MemberRef) {
|
function removePerson(list: PeopleKey, person: MemberRef) {
|
||||||
|
|
@ -256,27 +279,70 @@ function scaleIngredients(mealRecipe: MealRecipe) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSaveMeal() {
|
async function onSaveMeal() {
|
||||||
// Finalize any pending ingredient edits: drop blanks and zero-quantity placeholders
|
try {
|
||||||
// This helps when a user clicks Save without blurring the input field first.
|
// Blur any focused input so its change handlers run
|
||||||
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => {
|
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
|
||||||
const hasLine = typeof i.line === 'string' && i.line.trim().length > 0
|
document.activeElement.blur()
|
||||||
const qtyOk = typeof i.quantity === 'number' ? i.quantity > 0 : true
|
}
|
||||||
return hasLine && qtyOk
|
|
||||||
})
|
|
||||||
|
|
||||||
const saved = await saveMeal(toMealInput(meal))
|
// 1) Drop any empty-line rows and clear their dirty flags
|
||||||
|
const nonEmpty: Ingredient[] = []
|
||||||
|
for (const ing of meal.extraIngredients) {
|
||||||
|
const line = typeof ing.line === 'string' ? ing.line.trim() : ''
|
||||||
|
if (line.length === 0) {
|
||||||
|
// also clear dirty if present
|
||||||
|
if (dirtyLines.has(ing)) dirtyLines.delete(ing)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nonEmpty.push(ing)
|
||||||
|
}
|
||||||
|
meal.extraIngredients = nonEmpty
|
||||||
|
|
||||||
|
// 2) Build list of only the dirty lines that still exist in the array
|
||||||
|
const dirtyEntries: Array<{ ing: Ingredient; line: string }> = []
|
||||||
|
for (const [ing, line] of dirtyLines.entries()) {
|
||||||
|
// only consider ingredients still present
|
||||||
|
if (meal.extraIngredients.includes(ing)) {
|
||||||
|
const t = typeof line === 'string' ? line.trim() : ''
|
||||||
|
if (t.length > 0) dirtyEntries.push({ ing, line: t })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Parse only dirty lines
|
||||||
|
if (dirtyEntries.length > 0) {
|
||||||
|
const lines = dirtyEntries.map((e) => e.line)
|
||||||
|
const parsed = await (await import('@/api/sdk')).parseIngredients(lines)
|
||||||
|
// Replace corresponding rows by identity
|
||||||
|
parsed.forEach((p, idx) => {
|
||||||
|
const target = dirtyEntries[idx]?.ing
|
||||||
|
if (!target) return
|
||||||
|
const i = meal.extraIngredients.indexOf(target)
|
||||||
|
if (i >= 0) meal.extraIngredients.splice(i, 1, p)
|
||||||
|
// Clear dirty marker for this ingredient object
|
||||||
|
dirtyLines.delete(target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final safety: drop any zero-quantity items (should be rare post-parse)
|
||||||
|
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => (typeof i.quantity === 'number' ? i.quantity > 0 : true))
|
||||||
|
|
||||||
|
const saved = await saveMeal(toMealInput(meal))
|
||||||
if (saved && saved.id >= 0) {
|
if (saved && saved.id >= 0) {
|
||||||
Object.assign(meal, saved)
|
Object.assign(meal, saved)
|
||||||
router.push(toMealEdit(saved.id))
|
router.push(toMealEdit(saved.id))
|
||||||
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
|
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showAlert({
|
showAlert({
|
||||||
heading: 'Error saving meal',
|
heading: 'Error saving meal',
|
||||||
message: 'An error occurred while saving the meal',
|
message: 'An unknown error occurred while saving the meal',
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to save meal'
|
||||||
|
showAlert({ heading: 'Error saving meal', message, type: 'error' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue