mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
<Command :ignore-filter="true">
|
||||
<CommandInput v-model="search" :placeholder="localizedSearchPlaceholder" :display-value="_ => ''" />
|
||||
<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 }}
|
||||
</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 {
|
||||
|
||||
@@ -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() {
|
||||
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: query.value,
|
||||
q: searchQuery,
|
||||
locations: locIds,
|
||||
labels: labelIds,
|
||||
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) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -134,7 +134,8 @@
|
||||
"selector": {
|
||||
"no_results": "一致するものがありません",
|
||||
"placeholder": "選択してください…",
|
||||
"search_placeholder": "入力してください"
|
||||
"search_placeholder": "入力してください",
|
||||
"searching": "検索中…"
|
||||
},
|
||||
"view": {
|
||||
"selectable": {
|
||||
|
||||
@@ -134,7 +134,8 @@
|
||||
"selector": {
|
||||
"no_results": "返回为空",
|
||||
"placeholder": "选择…",
|
||||
"search_placeholder": "输入以搜索…"
|
||||
"search_placeholder": "输入以搜索…",
|
||||
"searching": "搜索中…"
|
||||
},
|
||||
"view": {
|
||||
"selectable": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user