ingredient debounce

This commit is contained in:
jableader 2025-11-02 19:41:33 +11:00
parent 9f63c8b091
commit f35739e732
4 changed files with 109 additions and 15 deletions

View file

@ -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.

View file

@ -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>

View file

@ -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>

View file

@ -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,13 +279,52 @@ 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
// 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)) const saved = await saveMeal(toMealInput(meal))
if (saved && saved.id >= 0) { if (saved && saved.id >= 0) {
@ -274,9 +336,13 @@ async function onSaveMeal() {
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>