Fix: add focus-triggered preloading to ItemSelector (#980)

* fix: add focus-triggered preloading to ItemSelector with proper error handling and complete localization

* Removed machine translated files

---------

Co-authored-by: Tonya <tonya@tokia.dev>
This commit is contained in:
Choong Jun Jin
2025-09-05 00:29:34 +09:00
committed by GitHub
parent d4e28e6f3b
commit 3ef25d6463
7 changed files with 115 additions and 21 deletions

View File

@@ -39,6 +39,8 @@
:items="results"
item-text="name"
no-results-text="Type to search..."
:is-loading="isLoading"
:trigger-search="triggerSearch"
/>
<FormTextField
ref="nameInput"
@@ -219,7 +221,7 @@
const router = useRouter();
const parent = ref();
const { query, results } = useItemSearch(api, { immediate: false });
const { query, results, isLoading, triggerSearch } = useItemSearch(api, { immediate: false });
const subItemCreateParam = useRouteQuery("subItemCreate", "n");
const subItemCreate = ref();

View File

@@ -18,7 +18,13 @@
<Command :ignore-filter="true">
<CommandInput v-model="search" :placeholder="localizedSearchPlaceholder" :display-value="_ => ''" />
<CommandEmpty>
{{ localizedNoResultsText }}
<div v-if="isLoading" class="flex items-center justify-center p-4">
<div class="size-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span class="ml-2">{{ t("components.item.selector.searching") }}</span>
</div>
<div v-else>
{{ localizedNoResultsText }}
</div>
</CommandEmpty>
<CommandList>
<CommandGroup>
@@ -65,6 +71,8 @@
noResultsText?: string;
placeholder?: string;
excludeItems?: ItemsObject[];
isLoading?: boolean;
triggerSearch?: () => Promise<boolean>;
}
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 {

View File

@@ -11,26 +11,74 @@ export function useItemSearch(client: UserClient, opts?: SearchOptions) {
const labels = ref<LabelSummary[]>([]);
const results = ref<ItemSummary[]>([]);
const includeArchived = ref(false);
const isLoading = ref(false);
const pendingQuery = ref<string | null>(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<boolean> {
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<boolean> {
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,
};
}

View File

@@ -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": {

View File

@@ -134,7 +134,8 @@
"selector": {
"no_results": "一致するものがありません",
"placeholder": "選択してください…",
"search_placeholder": "入力してください"
"search_placeholder": "入力してください",
"searching": "検索中…"
},
"view": {
"selectable": {

View File

@@ -134,7 +134,8 @@
"selector": {
"no_results": "返回为空",
"placeholder": "选择…",
"search_placeholder": "输入以搜索…"
"search_placeholder": "输入以搜索…",
"searching": "搜索中…"
},
"view": {
"selectable": {

View File

@@ -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()"
/>
<div class="flex flex-col gap-2">