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" :items="results"
item-text="name" item-text="name"
no-results-text="Type to search..." no-results-text="Type to search..."
:is-loading="isLoading"
:trigger-search="triggerSearch"
/> />
<FormTextField <FormTextField
ref="nameInput" ref="nameInput"
@@ -219,7 +221,7 @@
const router = useRouter(); const router = useRouter();
const parent = ref(); 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 subItemCreateParam = useRouteQuery("subItemCreate", "n");
const subItemCreate = ref(); const subItemCreate = ref();

View File

@@ -18,7 +18,13 @@
<Command :ignore-filter="true"> <Command :ignore-filter="true">
<CommandInput v-model="search" :placeholder="localizedSearchPlaceholder" :display-value="_ => ''" /> <CommandInput v-model="search" :placeholder="localizedSearchPlaceholder" :display-value="_ => ''" />
<CommandEmpty> <CommandEmpty>
<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 }} {{ localizedNoResultsText }}
</div>
</CommandEmpty> </CommandEmpty>
<CommandList> <CommandList>
<CommandGroup> <CommandGroup>
@@ -65,6 +71,8 @@
noResultsText?: string; noResultsText?: string;
placeholder?: string; placeholder?: string;
excludeItems?: ItemsObject[]; excludeItems?: ItemsObject[];
isLoading?: boolean;
triggerSearch?: () => Promise<boolean>;
} }
const emit = defineEmits(["update:modelValue", "update:search"]); const emit = defineEmits(["update:modelValue", "update:search"]);
@@ -79,12 +87,15 @@
noResultsText: undefined, noResultsText: undefined,
placeholder: undefined, placeholder: undefined,
excludeItems: undefined, excludeItems: undefined,
isLoading: false,
triggerSearch: undefined,
}); });
const id = useId(); const id = useId();
const open = ref(false); const open = ref(false);
const search = ref(props.search); const search = ref(props.search);
const value = useVModel(props, "modelValue", emit); const value = useVModel(props, "modelValue", emit);
const hasInitialSearch = ref(false);
const localizedSearchPlaceholder = computed( const localizedSearchPlaceholder = computed(
() => props.searchPlaceholder ?? t("components.item.selector.search_placeholder") () => props.searchPlaceholder ?? t("components.item.selector.search_placeholder")
@@ -92,6 +103,32 @@
const localizedNoResultsText = computed(() => props.noResultsText ?? t("components.item.selector.no_results")); const localizedNoResultsText = computed(() => props.noResultsText ?? t("components.item.selector.no_results"));
const localizedPlaceholder = computed(() => props.placeholder ?? t("components.item.selector.placeholder")); 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( watch(
() => props.search, () => props.search,
val => { val => {
@@ -107,7 +144,7 @@
); );
function isStrings(arr: string[] | ItemsObject[]): arr is string[] { 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 { 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 labels = ref<LabelSummary[]>([]);
const results = ref<ItemSummary[]>([]); const results = ref<ItemSummary[]>([]);
const includeArchived = ref(false); const includeArchived = ref(false);
const isLoading = ref(false);
const pendingQuery = ref<string | null>(null);
watchDebounced(query, search, { debounce: 250, maxWait: 1000 }); watchDebounced(query, search, { debounce: 250, maxWait: 1000 });
async function search() { 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 locIds = locations.value.map(l => l.id);
const labelIds = labels.value.map(l => l.id); const labelIds = labels.value.map(l => l.id);
const { data, error } = await client.items.getAll({ const { data, error } = await client.items.getAll({
q: query.value, q: searchQuery,
locations: locIds, locations: locIds,
labels: labelIds, labels: labelIds,
includeArchived: includeArchived.value, includeArchived: includeArchived.value,
}); });
if (error) {
return; 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) { if (opts?.immediate) {
search(); search()
.then(success => {
if (!success) {
console.error("Initial search failed");
}
})
.catch(err => {
console.error("Initial search error:", err);
});
} }
return { return {
@@ -38,5 +86,7 @@ export function useItemSearch(client: UserClient, opts?: SearchOptions) {
results, results,
locations, locations,
labels, labels,
isLoading,
triggerSearch,
}; };
} }

View File

@@ -134,7 +134,8 @@
"selector": { "selector": {
"no_results": "No Results Found", "no_results": "No Results Found",
"placeholder": "Select…", "placeholder": "Select…",
"search_placeholder": "Type to search…" "search_placeholder": "Type to search…",
"searching": "Searching…"
}, },
"view": { "view": {
"selectable": { "selectable": {

View File

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

View File

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

View File

@@ -424,7 +424,7 @@
} as unknown as ItemField); } as unknown as ItemField);
} }
const { query, results } = useItemSearch(api, { immediate: false }); const { query, results, isLoading, triggerSearch } = useItemSearch(api, { immediate: false });
const parent = ref(); const parent = ref();
async function keyboardSave(e: KeyboardEvent) { async function keyboardSave(e: KeyboardEvent) {
@@ -591,6 +591,8 @@
:label="$t('items.parent_item')" :label="$t('items.parent_item')"
no-results-text="Type to search..." no-results-text="Type to search..."
:exclude-items="[item]" :exclude-items="[item]"
:is-loading="isLoading"
:trigger-search="triggerSearch"
@update:model-value="maybeSyncWithParentLocation()" @update:model-value="maybeSyncWithParentLocation()"
/> />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">