mirror of
https://github.com/hay-kot/homebox.git
synced 2025-08-03 08:10:28 +00:00
extract search functionality
This commit is contained in:
parent
55724f4c17
commit
b8374d8cc8
4 changed files with 231 additions and 7 deletions
149
frontend/components/Form/Autocomplete.vue
Normal file
149
frontend/components/Form/Autocomplete.vue
Normal file
|
@ -0,0 +1,149 @@
|
|||
<template>
|
||||
<div ref="menu" class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
</label>
|
||||
<div class="dropdown dropdown-top sm:dropdown-end">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="isearch"
|
||||
tabindex="0"
|
||||
class="input w-full items-center flex flex-wrap border border-gray-400 rounded-lg"
|
||||
@keyup.enter="selectFirst"
|
||||
/>
|
||||
<button
|
||||
v-if="!!modelValue && Object.keys(modelValue).length !== 0"
|
||||
style="transform: translateY(-50%)"
|
||||
class="top-1/2 absolute right-2 btn btn-xs btn-circle no-animation"
|
||||
@click="clear"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
style="display: inline"
|
||||
class="dropdown-content mb-1 menu shadow border border-gray-400 rounded bg-base-100 w-full z-[9999] max-h-60 overflow-y-scroll"
|
||||
>
|
||||
<li v-for="(obj, idx) in filtered" :key="idx">
|
||||
<button type="button" @click="select(obj)">
|
||||
{{ usingObjects ? obj[itemText] : obj }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="hidden first:flex">
|
||||
<button disabled>
|
||||
{{ noResultsText }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
type ItemsObject = {
|
||||
text?: string;
|
||||
value?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
modelValue: string | ItemsObject;
|
||||
items: string[] | ItemsObject[];
|
||||
itemText?: keyof ItemsObject;
|
||||
itemValue?: keyof ItemsObject;
|
||||
search?: string;
|
||||
noResultsText?: string;
|
||||
}
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:search"]);
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: "",
|
||||
modelValue: "",
|
||||
items: () => [],
|
||||
itemText: "text",
|
||||
itemValue: "value",
|
||||
search: "",
|
||||
noResultsText: "No Results Found",
|
||||
});
|
||||
|
||||
function clear() {
|
||||
select(value.value);
|
||||
}
|
||||
|
||||
const isearch = ref("");
|
||||
watch(isearch, () => {
|
||||
internalSearch.value = isearch.value;
|
||||
});
|
||||
|
||||
const internalSearch = useVModel(props, "search", emit);
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
|
||||
const usingObjects = computed(() => {
|
||||
return props.items.length > 0 && typeof props.items[0] === "object";
|
||||
});
|
||||
|
||||
/**
|
||||
* isStrings is a type guard function to check if the items are an array of string
|
||||
*/
|
||||
function isStrings(_arr: string[] | ItemsObject[]): _arr is string[] {
|
||||
return !usingObjects.value;
|
||||
}
|
||||
|
||||
function selectFirst() {
|
||||
if (filtered.value.length > 0) {
|
||||
select(filtered.value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
value,
|
||||
() => {
|
||||
if (value.value) {
|
||||
if (typeof value.value === "string") {
|
||||
isearch.value = value.value;
|
||||
} else {
|
||||
isearch.value = value.value[props.itemText] as string;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
function select(obj: string | ItemsObject) {
|
||||
if (isStrings(props.items)) {
|
||||
if (obj === value.value) {
|
||||
value.value = "";
|
||||
return;
|
||||
}
|
||||
value.value = obj;
|
||||
} else {
|
||||
if (obj === value.value) {
|
||||
value.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
value.value = obj;
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!isearch.value || isearch.value === "") {
|
||||
return props.items;
|
||||
}
|
||||
|
||||
if (isStrings(props.items)) {
|
||||
return props.items.filter(item => item.toLowerCase().includes(isearch.value.toLowerCase()));
|
||||
} else {
|
||||
return props.items.filter(item => {
|
||||
if (props.itemText && props.itemText in item) {
|
||||
return (item[props.itemText] as string).toLowerCase().includes(isearch.value.toLowerCase());
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
36
frontend/composables/use-item-search.ts
Normal file
36
frontend/composables/use-item-search.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { ItemSummary, LabelSummary, LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||
import { UserClient } from "~~/lib/api/user";
|
||||
|
||||
type SearchOptions = {
|
||||
immediate?: boolean;
|
||||
};
|
||||
|
||||
export function useItemSearch(client: UserClient, opts?: SearchOptions) {
|
||||
const query = ref("");
|
||||
const locations = ref<LocationSummary[]>([]);
|
||||
const labels = ref<LabelSummary[]>([]);
|
||||
const results = ref<ItemSummary[]>([]);
|
||||
|
||||
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 });
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
results.value = data.items;
|
||||
}
|
||||
|
||||
if (opts?.immediate) {
|
||||
search();
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
results,
|
||||
locations,
|
||||
labels,
|
||||
};
|
||||
}
|
|
@ -4,6 +4,7 @@
|
|||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
import { capitalize } from "~~/lib/strings";
|
||||
import Autocomplete from "~~/components/Form/Autocomplete.vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["auth"],
|
||||
|
@ -37,6 +38,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
if (data.parent) {
|
||||
parent.value = data.parent;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
|
@ -49,7 +54,7 @@
|
|||
...item.value,
|
||||
locationId: item.value.location?.id,
|
||||
labelIds: item.value.labels.map(l => l.id),
|
||||
parentId: null,
|
||||
parentId: parent.value ? parent.value.id : null,
|
||||
};
|
||||
|
||||
const { error } = await api.items.update(itemId.value, payload);
|
||||
|
@ -258,7 +263,6 @@
|
|||
|
||||
async function updateAttachment() {
|
||||
editState.loading = true;
|
||||
console.log(editState.type);
|
||||
const { error, data } = await api.items.updateAttachment(itemId.value, editState.id, {
|
||||
title: editState.title,
|
||||
type: editState.type,
|
||||
|
@ -308,6 +312,9 @@
|
|||
timeValue: null,
|
||||
});
|
||||
}
|
||||
|
||||
const { query, results } = useItemSearch(api, { immediate: false });
|
||||
const parent = ref();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -364,6 +371,16 @@
|
|||
compare-key="id"
|
||||
/>
|
||||
<FormMultiselect v-model="item.labels" label="Labels" :items="labels ?? []" />
|
||||
|
||||
<Autocomplete
|
||||
v-if="!preferences.editorSimpleView"
|
||||
v-model="parent"
|
||||
v-model:search="query"
|
||||
:items="results"
|
||||
item-text="name"
|
||||
label="Parent Item"
|
||||
no-results-text="Type to search..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-300 sm:p-0">
|
||||
|
|
|
@ -76,6 +76,10 @@
|
|||
name: "Description",
|
||||
text: item.value?.description,
|
||||
},
|
||||
{
|
||||
name: "Quantity",
|
||||
text: item.value?.quantity,
|
||||
},
|
||||
{
|
||||
name: "Serial Number",
|
||||
text: item.value?.serialNumber,
|
||||
|
@ -272,12 +276,23 @@
|
|||
<span class="text-base-content">
|
||||
{{ item ? item.name : "" }}
|
||||
</span>
|
||||
<div v-if="item.parent" class="text-sm breadcrumbs pb-0">
|
||||
<ul class="text-base-content/70">
|
||||
<li>
|
||||
<NuxtLink :to="`/item/${item.parent.id}`"> {{ item.parent.name }}</NuxtLink>
|
||||
</li>
|
||||
<li>{{ item.name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<template #description>
|
||||
<p class="text-sm text-base-content font-bold pb-0 mb-0">
|
||||
{{ item.location.name }} - Quantity {{ item.quantity }}
|
||||
</p>
|
||||
<div v-if="item.labels && item.labels.length > 0" class="flex flex-wrap gap-3 mt-3">
|
||||
<LabelChip v-for="label in item.labels" :key="label.id" class="badge-primary" :label="label" />
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
<NuxtLink ref="badge" class="badge p-3" :to="`/location/${item.location.id}`">
|
||||
<Icon name="heroicons-map-pin" class="mr-2 swap-on"></Icon>
|
||||
{{ item.location.name }}
|
||||
</NuxtLink>
|
||||
<template v-if="item.labels && item.labels.length > 0">
|
||||
<LabelChip v-for="label in item.labels" :key="label.id" class="badge-primary" :label="label" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</BaseSectionHeader>
|
||||
|
@ -363,5 +378,12 @@
|
|||
</BaseCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="my-6 px-3">
|
||||
<BaseSectionHeader v-if="item && item.children && item.children.length > 0"> Child Items </BaseSectionHeader>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<ItemCard v-for="child in item.children" :key="child.id" :item="child" />
|
||||
</div>
|
||||
</section>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue