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"
|
: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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -134,7 +134,8 @@
|
|||||||
"selector": {
|
"selector": {
|
||||||
"no_results": "一致するものがありません",
|
"no_results": "一致するものがありません",
|
||||||
"placeholder": "選択してください…",
|
"placeholder": "選択してください…",
|
||||||
"search_placeholder": "入力してください"
|
"search_placeholder": "入力してください",
|
||||||
|
"searching": "検索中…"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"selectable": {
|
"selectable": {
|
||||||
|
|||||||
@@ -134,7 +134,8 @@
|
|||||||
"selector": {
|
"selector": {
|
||||||
"no_results": "返回为空",
|
"no_results": "返回为空",
|
||||||
"placeholder": "选择…",
|
"placeholder": "选择…",
|
||||||
"search_placeholder": "输入以搜索…"
|
"search_placeholder": "输入以搜索…",
|
||||||
|
"searching": "搜索中…"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"selectable": {
|
"selectable": {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user