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 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.
|
||||
|
||||
---
|
||||
|
||||
## 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">
|
||||
<ingredient-line
|
||||
:ingredient="ingredient"
|
||||
@update-line="(ing, line) => emit('on-update-line', ing, line)"
|
||||
@update-ingredient="updateIngredient"
|
||||
@update-product-link="updateProduct"
|
||||
/>
|
||||
|
|
@ -73,11 +74,13 @@ const emit = defineEmits<{
|
|||
(e: 'on-add'): void
|
||||
(e: 'on-delete', ingredient: 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
|
||||
}>()
|
||||
|
||||
const editing = ref(props.editOnly ?? false)
|
||||
|
||||
|
||||
async function updateProduct(): Promise<void> {
|
||||
// parseProduct is not available in v2 API; ignore for now
|
||||
}
|
||||
|
|
@ -92,6 +95,7 @@ function toggleEditing() {
|
|||
editing.value = !editing.value
|
||||
emit('on-editing', editing.value)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<input
|
||||
v-model="ingredientText"
|
||||
placeholder="Enter an ingredient"
|
||||
@input="onInput"
|
||||
@keyup.enter="updateIngredient"
|
||||
@blur="updateIngredient"
|
||||
>
|
||||
|
|
@ -30,6 +31,7 @@ import type { Ingredient } from '@/domain/types'
|
|||
|
||||
const props = defineProps<{ ingredient: Ingredient }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update-line', ingredient: Ingredient, newLine: string): void
|
||||
(e: 'update-ingredient', ingredient: Ingredient, newLine: 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 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>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@
|
|||
@on-add="addIngredient"
|
||||
@on-delete="deleteIngredient"
|
||||
@on-update-ingredient="updateIngredient"
|
||||
@on-update-line="updateIngredientLine"
|
||||
@on-editing="onEditAdditionalIngredients"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -152,8 +153,13 @@ const route = useRoute()
|
|||
const router = useRouter()
|
||||
const { show: showAlert } = useAlert()
|
||||
|
||||
// No child refs; parent maintains source of truth for lines
|
||||
|
||||
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>({
|
||||
id: -1,
|
||||
suggestedDate: new Date(),
|
||||
|
|
@ -207,6 +213,23 @@ function deleteIngredient(ingredient: Ingredient) {
|
|||
|
||||
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
|
||||
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) {
|
||||
|
|
@ -256,13 +279,52 @@ function scaleIngredients(mealRecipe: MealRecipe) {
|
|||
}
|
||||
|
||||
async function onSaveMeal() {
|
||||
// Finalize any pending ingredient edits: drop blanks and zero-quantity placeholders
|
||||
// This helps when a user clicks Save without blurring the input field first.
|
||||
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => {
|
||||
const hasLine = typeof i.line === 'string' && i.line.trim().length > 0
|
||||
const qtyOk = typeof i.quantity === 'number' ? i.quantity > 0 : true
|
||||
return hasLine && qtyOk
|
||||
try {
|
||||
// Blur any focused input so its change handlers run
|
||||
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
@ -274,9 +336,13 @@ async function onSaveMeal() {
|
|||
|
||||
showAlert({
|
||||
heading: 'Error saving meal',
|
||||
message: 'An error occurred while saving the meal',
|
||||
message: 'An unknown error occurred while saving the meal',
|
||||
type: 'error',
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save meal'
|
||||
showAlert({ heading: 'Error saving meal', message, type: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue