diff --git a/frontend/components/Item/CreateModal.vue b/frontend/components/Item/CreateModal.vue index 3d2c0b26..665e43a0 100644 --- a/frontend/components/Item/CreateModal.vue +++ b/frontend/components/Item/CreateModal.vue @@ -39,6 +39,8 @@ :items="results" item-text="name" no-results-text="Type to search..." + :is-loading="isLoading" + :trigger-search="triggerSearch" /> - {{ localizedNoResultsText }} +
+
+ {{ t("components.item.selector.searching") }} +
+
+ {{ localizedNoResultsText }} +
@@ -65,6 +71,8 @@ noResultsText?: string; placeholder?: string; excludeItems?: ItemsObject[]; + isLoading?: boolean; + triggerSearch?: () => Promise; } const emit = defineEmits(["update:modelValue", "update:search"]); @@ -79,12 +87,15 @@ noResultsText: undefined, placeholder: undefined, excludeItems: undefined, + isLoading: false, + triggerSearch: undefined, }); const id = useId(); const open = ref(false); const search = ref(props.search); const value = useVModel(props, "modelValue", emit); + const hasInitialSearch = ref(false); const localizedSearchPlaceholder = computed( () => props.searchPlaceholder ?? t("components.item.selector.search_placeholder") @@ -92,6 +103,32 @@ const localizedNoResultsText = computed(() => props.noResultsText ?? t("components.item.selector.no_results")); const localizedPlaceholder = computed(() => props.placeholder ?? t("components.item.selector.placeholder")); + // Trigger search when popover opens for the first time if no results exist + async function handlePopoverOpen() { + if (hasInitialSearch.value || props.items.length !== 0 || !props.triggerSearch) return; + + try { + const success = await props.triggerSearch(); + if (success) { + // Only mark as attempted after successful completion + hasInitialSearch.value = true; + } + // If not successful, leave hasInitialSearch false to allow retries + } catch (err) { + console.error("triggerSearch failed:", err); + // Leave hasInitialSearch false to allow retries on subsequent opens + } + } + + watch( + () => open.value, + isOpen => { + if (isOpen) { + handlePopoverOpen(); + } + } + ); + watch( () => props.search, val => { @@ -107,7 +144,7 @@ ); function isStrings(arr: string[] | ItemsObject[]): arr is string[] { - return typeof arr[0] === "string"; + return arr.length > 0 && typeof arr[0] === "string"; } function displayValue(item: string | ItemsObject | null | undefined): string { diff --git a/frontend/composables/use-item-search.ts b/frontend/composables/use-item-search.ts index 7ea4d6bb..095bd434 100644 --- a/frontend/composables/use-item-search.ts +++ b/frontend/composables/use-item-search.ts @@ -11,26 +11,74 @@ export function useItemSearch(client: UserClient, opts?: SearchOptions) { const labels = ref([]); const results = ref([]); const includeArchived = ref(false); + const isLoading = ref(false); + const pendingQuery = ref(null); watchDebounced(query, search, { debounce: 250, maxWait: 1000 }); - async function search() { - const locIds = locations.value.map(l => l.id); - const labelIds = labels.value.map(l => l.id); - - const { data, error } = await client.items.getAll({ - q: query.value, - locations: locIds, - labels: labelIds, - includeArchived: includeArchived.value, - }); - if (error) { - return; + async function search(): Promise { + if (isLoading.value) { + // Store the latest query to run after current search completes + pendingQuery.value = query.value; + return false; + } + + const searchQuery = query.value; + isLoading.value = true; + try { + const locIds = locations.value.map(l => l.id); + const labelIds = labels.value.map(l => l.id); + + const { data, error } = await client.items.getAll({ + q: searchQuery, + locations: locIds, + labels: labelIds, + includeArchived: includeArchived.value, + }); + + if (error || !data) { + console.error("useItemSearch.search error:", error); + return false; + } + + results.value = data.items ?? []; + return true; + } finally { + isLoading.value = false; + + // If user changed query while we were searching, run again with the latest query + if (pendingQuery.value !== null && pendingQuery.value !== searchQuery) { + const nextQuery = pendingQuery.value; + pendingQuery.value = null; + // Use nextTick to avoid potential recursion issues + await nextTick(); + if (query.value === nextQuery) { + await search(); + } + } else { + pendingQuery.value = null; + } + } + } + + async function triggerSearch(): Promise { + try { + return await search(); + } catch (err) { + console.error("triggerSearch error:", err); + return false; } - results.value = data.items; } if (opts?.immediate) { - search(); + search() + .then(success => { + if (!success) { + console.error("Initial search failed"); + } + }) + .catch(err => { + console.error("Initial search error:", err); + }); } return { @@ -38,5 +86,7 @@ export function useItemSearch(client: UserClient, opts?: SearchOptions) { results, locations, labels, + isLoading, + triggerSearch, }; } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index f29cfd3b..7c628b24 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -134,7 +134,8 @@ "selector": { "no_results": "No Results Found", "placeholder": "Select…", - "search_placeholder": "Type to search…" + "search_placeholder": "Type to search…", + "searching": "Searching…" }, "view": { "selectable": { diff --git a/frontend/locales/ja-JP.json b/frontend/locales/ja-JP.json index d46bf5d5..6e66e19e 100644 --- a/frontend/locales/ja-JP.json +++ b/frontend/locales/ja-JP.json @@ -134,7 +134,8 @@ "selector": { "no_results": "一致するものがありません", "placeholder": "選択してください…", - "search_placeholder": "入力してください" + "search_placeholder": "入力してください", + "searching": "検索中…" }, "view": { "selectable": { diff --git a/frontend/locales/zh-CN.json b/frontend/locales/zh-CN.json index 2c2f2c08..9cb61173 100644 --- a/frontend/locales/zh-CN.json +++ b/frontend/locales/zh-CN.json @@ -134,7 +134,8 @@ "selector": { "no_results": "返回为空", "placeholder": "选择…", - "search_placeholder": "输入以搜索…" + "search_placeholder": "输入以搜索…", + "searching": "搜索中…" }, "view": { "selectable": { diff --git a/frontend/pages/item/[id]/index/edit.vue b/frontend/pages/item/[id]/index/edit.vue index c7648d14..442b98c3 100644 --- a/frontend/pages/item/[id]/index/edit.vue +++ b/frontend/pages/item/[id]/index/edit.vue @@ -424,7 +424,7 @@ } as unknown as ItemField); } - const { query, results } = useItemSearch(api, { immediate: false }); + const { query, results, isLoading, triggerSearch } = useItemSearch(api, { immediate: false }); const parent = ref(); async function keyboardSave(e: KeyboardEvent) { @@ -591,6 +591,8 @@ :label="$t('items.parent_item')" no-results-text="Type to search..." :exclude-items="[item]" + :is-loading="isLoading" + :trigger-search="triggerSearch" @update:model-value="maybeSyncWithParentLocation()" />