feat: rebuild search UI w/ new filters (#269)

This commit is contained in:
Hayden 2023-02-09 17:47:41 -09:00 committed by GitHub
parent ce2fc7712a
commit ab22ea6a25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 524 additions and 134 deletions

View file

@ -308,22 +308,6 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
qb = qb.Where(item.Archived(false)) qb = qb.Where(item.Archived(false))
} }
if len(q.LabelIDs) > 0 {
labels := make([]predicate.Item, 0, len(q.LabelIDs))
for _, l := range q.LabelIDs {
labels = append(labels, item.HasLabelWith(label.ID(l)))
}
qb = qb.Where(item.Or(labels...))
}
if len(q.LocationIDs) > 0 {
locations := make([]predicate.Item, 0, len(q.LocationIDs))
for _, l := range q.LocationIDs {
locations = append(locations, item.HasLocationWith(location.ID(l)))
}
qb = qb.Where(item.Or(locations...))
}
if q.Search != "" { if q.Search != "" {
qb.Where( qb.Where(
item.Or( item.Or(
@ -338,10 +322,37 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
qb = qb.Where(item.AssetID(q.AssetID.Int())) qb = qb.Where(item.AssetID(q.AssetID.Int()))
} }
// Filters within this block define a AND relationship where each subset
// of filters is OR'd together.
//
// The goal is to allow matches like where the item has
// - one of the selected labels AND
// - one of the selected locations AND
// - one of the selected fields key/value matches
var andPredicates []predicate.Item
{
if len(q.LabelIDs) > 0 {
labelPredicates := make([]predicate.Item, 0, len(q.LabelIDs))
for _, l := range q.LabelIDs {
labelPredicates = append(labelPredicates, item.HasLabelWith(label.ID(l)))
}
andPredicates = append(andPredicates, item.Or(labelPredicates...))
}
if len(q.LocationIDs) > 0 {
locationPredicates := make([]predicate.Item, 0, len(q.LocationIDs))
for _, l := range q.LocationIDs {
locationPredicates = append(locationPredicates, item.HasLocationWith(location.ID(l)))
}
andPredicates = append(andPredicates, item.Or(locationPredicates...))
}
if len(q.Fields) > 0 { if len(q.Fields) > 0 {
predicates := make([]predicate.Item, 0, len(q.Fields)) fieldPredicates := make([]predicate.Item, 0, len(q.Fields))
for _, f := range q.Fields { for _, f := range q.Fields {
predicates = append(predicates, item.HasFieldsWith( fieldPredicates = append(fieldPredicates, item.HasFieldsWith(
itemfield.And( itemfield.And(
itemfield.Name(f.Name), itemfield.Name(f.Name),
itemfield.TextValue(f.Value), itemfield.TextValue(f.Value),
@ -349,7 +360,12 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
)) ))
} }
qb = qb.Where(item.Or(predicates...)) andPredicates = append(andPredicates, item.Or(fieldPredicates...))
}
}
if len(andPredicates) > 0 {
qb = qb.Where(item.And(andPredicates...))
} }
count, err := qb.Count(ctx) count, err := qb.Count(ctx)

View file

@ -6,3 +6,21 @@
text-transform: none !important; text-transform: none !important;
} }
/* transparent subtle scrollbar */
::-webkit-scrollbar {
width: 0.2em;
background-color: #F5F5F5;
}
::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,.2);
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
background-color: #F5F5F5;
}
::-webkit-scrollbar-thumb:hover {
background-color: #9B9B9B;
}

View file

@ -0,0 +1,157 @@
<template>
<div>
<Combobox v-model="value">
<ComboboxLabel class="label">
<span class="label-text">{{ label }}</span>
</ComboboxLabel>
<div class="relative">
<ComboboxInput
:display-value="i => extractDisplay(i as SupportValues)"
class="w-full input input-bordered"
@change="search = $event.target.value"
/>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none">
<Icon name="mdi-chevron-down" class="w-5 h-5" />
</ComboboxButton>
<ComboboxOptions
v-if="computedItems.length > 0"
class="absolute dropdown-content z-10 mt-2 max-h-60 w-full overflow-auto rounded-md card bg-base-100 border border-gray-400"
>
<ComboboxOption
v-for="item in computedItems"
:key="item.id"
v-slot="{ active, selected }"
:value="item.value"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9 duration-75 ease-in-out transition-colors',
active ? 'bg-primary text-white' : 'text-gray-900',
]"
>
<slot name="display" v-bind="{ item: item, selected, active }">
<span :class="['block truncate', selected && 'font-semibold']">
{{ item.display }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex text-primary items-center pr-4',
active ? 'text-primary-content' : 'bg-primary',
]"
>
<Icon name="mdi-check" class="h-5 w-5" aria-hidden="true" />
</span>
</slot>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
ComboboxButton,
ComboboxLabel,
} from "@headlessui/vue";
type SupportValues = string | { [key: string]: any };
type ComboItem = {
display: string;
value: SupportValues;
id: number;
};
type Props = {
label: string;
modelValue: SupportValues | null | undefined;
items: string[] | object[];
display?: string;
multiple?: boolean;
};
const emit = defineEmits(["update:modelValue", "update:search"]);
const props = withDefaults(defineProps<Props>(), {
label: "",
modelValue: "",
display: "text",
multiple: false,
});
const search = ref("");
const value = useVModel(props, "modelValue", emit);
function extractDisplay(item?: SupportValues): string {
if (!item) {
return "";
}
if (typeof item === "string") {
return item;
}
if (props.display in item) {
return item[props.display] as string;
}
// Try these options as well
const fallback = ["name", "title", "display", "value"];
for (let i = 0; i < fallback.length; i++) {
const key = fallback[i];
if (key in item) {
return item[key] as string;
}
}
return "";
}
const computedItems = computed<ComboItem[]>(() => {
const list: ComboItem[] = [];
for (let i = 0; i < props.items.length; i++) {
const item = props.items[i];
const out: Partial<ComboItem> = {
id: i,
value: item,
};
switch (typeof item) {
case "string":
out.display = item;
break;
case "object":
// @ts-ignore - up to the user to provide a valid display key
out.display = item[props.display] as string;
break;
default:
out.display = "";
break;
}
if (search.value && out.display) {
const foldSearch = search.value.toLowerCase();
const foldDisplay = out.display.toLowerCase();
if (foldDisplay.startsWith(foldSearch)) {
list.push(out as ComboItem);
}
continue;
}
list.push(out as ComboItem);
}
return list;
});
</script>

View file

@ -1,37 +1,43 @@
<template> <template>
<FormAutocomplete <FormAutocomplete2 v-if="locations" v-model="value" :items="locations" display="name" label="Parent Location">
v-model="value" <template #display="{ item, selected, active }">
v-model:search="form.search" <div>
:items="locations" <div class="flex w-full">
item-text="display" {{ cast(item.value).name }}
item-value="id" <span
item-search="name" v-if="selected"
label="Parent Location" :class="['absolute inset-y-0 right-0 flex items-center pr-4', active ? 'text-white' : 'text-primary']"
> >
<template #display="{ item }"> <Icon name="mdi-check" class="h-5 w-5" aria-hidden="true" />
<div> </span>
<div> </div>
{{ item.name }} <div v-if="cast(item.value).name != cast(item.value).treeString" class="text-xs mt-1">
{{ cast(item.value).treeString }}
</div> </div>
<div v-if="item.name != item.display" class="text-xs mt-1">{{ item.display }}</div>
</div> </div>
</template> </template>
</FormAutocomplete> </FormAutocomplete2>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { FlatTreeItem, useFlatLocations } from "~~/composables/use-location-helpers";
import { LocationSummary } from "~~/lib/api/types/data-contracts"; import { LocationSummary } from "~~/lib/api/types/data-contracts";
type Props = { type Props = {
modelValue?: LocationSummary | null; modelValue?: LocationSummary | null;
}; };
const props = defineProps<Props>(); // Cast the type of the item to a FlatTreeItem so we can get type "safety" in the template
// Note that this does not actually change the type of the item, it just tells the compiler
// that the type is FlatTreeItem. We must keep this in sync with the type of the items
function cast(value: any): FlatTreeItem {
return value as FlatTreeItem;
}
const props = defineProps<Props>();
const value = useVModel(props, "modelValue"); const value = useVModel(props, "modelValue");
const locations = await useFlatLocations(); const locations = await useFlatLocations();
const form = ref({ const form = ref({
parent: null as LocationSummary | null, parent: null as LocationSummary | null,
search: "", search: "",

View file

@ -0,0 +1,104 @@
<template>
<div ref="el" class="dropdown" :class="{ 'dropdown-open': dropdownOpen }">
<button ref="btn" tabindex="0" class="btn btn-xs" @click="toggle">
{{ label }} {{ len }} <Icon name="mdi-chevron-down" class="h-4 w-4" />
</button>
<div tabindex="0" class="dropdown-content mt-1 w-64 shadow bg-base-100 rounded-md">
<div class="pt-4 px-4 shadow-sm mb-1">
<input v-model="search" type="text" placeholder="Search…" class="input input-sm input-bordered w-full mb-2" />
</div>
<div class="overflow-y-auto max-h-72 divide-y">
<label
v-for="v in selectedView"
:key="v"
class="cursor-pointer px-4 label flex justify-between hover:bg-base-200"
>
<span class="label-text mr-2">
<slot name="display" v-bind="{ item: v }">
{{ v[display] }}
</slot>
</span>
<input v-model="selected" type="checkbox" :value="v" class="checkbox checkbox-sm checkbox-primary" />
</label>
<hr v-if="selected.length > 0" />
<label
v-for="v in unselected"
:key="v"
class="cursor-pointer px-4 label flex justify-between hover:bg-base-200"
>
<span class="label-text mr-2">
<slot name="display" v-bind="{ item: v }">
{{ v[display] }}
</slot>
</span>
<input v-model="selected" type="checkbox" :value="v" class="checkbox checkbox-sm checkbox-primary" />
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
type Props = {
label: string;
options: any[];
display?: string;
modelValue: any[];
};
const btn = ref<HTMLButtonElement>();
const search = ref("");
const searchFold = computed(() => search.value.toLowerCase());
const dropdownOpen = ref(false);
const el = ref();
function toggle() {
dropdownOpen.value = !dropdownOpen.value;
if (!dropdownOpen.value) {
btn.value?.blur();
}
}
onClickOutside(el, () => {
dropdownOpen.value = false;
});
watch(dropdownOpen, val => {
console.log(val);
});
const emit = defineEmits(["update:modelValue"]);
const props = withDefaults(defineProps<Props>(), {
label: "",
display: "name",
modelValue: () => [],
});
const len = computed(() => {
return selected.value.length > 0 ? `(${selected.value.length})` : "";
});
const selectedView = computed(() => {
return selected.value.filter(o => {
if (searchFold.value.length > 0) {
return o[props.display].toLowerCase().includes(searchFold.value);
}
return true;
});
});
const selected = useVModel(props, "modelValue", emit);
const unselected = computed(() => {
return props.options.filter(o => {
if (searchFold.value.length > 0) {
return o[props.display].toLowerCase().includes(searchFold.value) && !selected.value.includes(o);
}
return !selected.value.includes(o);
});
});
</script>
<style scoped></style>

View file

@ -4,7 +4,7 @@ import { TreeItem } from "~~/lib/api/types/data-contracts";
export interface FlatTreeItem { export interface FlatTreeItem {
id: string; id: string;
name: string; name: string;
display: string; treeString: string;
} }
export function flatTree(tree: TreeItem[]): Ref<FlatTreeItem[]> { export function flatTree(tree: TreeItem[]): Ref<FlatTreeItem[]> {
@ -18,7 +18,7 @@ export function flatTree(tree: TreeItem[]): Ref<FlatTreeItem[]> {
v.value.push({ v.value.push({
id: item.id, id: item.id,
name: item.name, name: item.name,
display: display + item.name, treeString: display + item.name,
}); });
if (item.children) { if (item.children) {
flatten(item.children, display + item.name + " > "); flatten(item.children, display + item.name + " > ");

View file

@ -17,7 +17,7 @@ export interface DocumentOut {
} }
export interface Group { export interface Group {
createdAt: Date; createdAt: string;
currency: string; currency: string;
id: string; id: string;
name: string; name: string;
@ -39,7 +39,7 @@ export interface GroupUpdate {
} }
export interface ItemAttachment { export interface ItemAttachment {
createdAt: Date; createdAt: string;
document: DocumentOut; document: DocumentOut;
id: string; id: string;
type: string; type: string;
@ -76,7 +76,7 @@ export interface ItemOut {
assetId: string; assetId: string;
attachments: ItemAttachment[]; attachments: ItemAttachment[];
children: ItemSummary[]; children: ItemSummary[];
createdAt: Date; createdAt: string;
description: string; description: string;
fields: ItemField[]; fields: ItemField[];
id: string; id: string;
@ -112,7 +112,7 @@ export interface ItemOut {
export interface ItemSummary { export interface ItemSummary {
archived: boolean; archived: boolean;
createdAt: Date; createdAt: string;
description: string; description: string;
id: string; id: string;
insured: boolean; insured: boolean;
@ -169,7 +169,7 @@ export interface LabelCreate {
} }
export interface LabelOut { export interface LabelOut {
createdAt: Date; createdAt: string;
description: string; description: string;
id: string; id: string;
items: ItemSummary[]; items: ItemSummary[];
@ -178,7 +178,7 @@ export interface LabelOut {
} }
export interface LabelSummary { export interface LabelSummary {
createdAt: Date; createdAt: string;
description: string; description: string;
id: string; id: string;
name: string; name: string;
@ -193,7 +193,7 @@ export interface LocationCreate {
export interface LocationOut { export interface LocationOut {
children: LocationSummary[]; children: LocationSummary[];
createdAt: Date; createdAt: string;
description: string; description: string;
id: string; id: string;
items: ItemSummary[]; items: ItemSummary[];
@ -203,7 +203,7 @@ export interface LocationOut {
} }
export interface LocationOutCount { export interface LocationOutCount {
createdAt: Date; createdAt: string;
description: string; description: string;
id: string; id: string;
itemCount: number; itemCount: number;
@ -212,7 +212,7 @@ export interface LocationOutCount {
} }
export interface LocationSummary { export interface LocationSummary {
createdAt: Date; createdAt: string;
description: string; description: string;
id: string; id: string;
name: string; name: string;
@ -229,7 +229,7 @@ export interface LocationUpdate {
export interface MaintenanceEntry { export interface MaintenanceEntry {
/** @example "0" */ /** @example "0" */
cost: string; cost: string;
date: string; date: Date;
description: string; description: string;
id: string; id: string;
name: string; name: string;
@ -238,7 +238,7 @@ export interface MaintenanceEntry {
export interface MaintenanceEntryCreate { export interface MaintenanceEntryCreate {
/** @example "0" */ /** @example "0" */
cost: string; cost: string;
date: string; date: Date;
description: string; description: string;
name: string; name: string;
} }
@ -246,7 +246,7 @@ export interface MaintenanceEntryCreate {
export interface MaintenanceEntryUpdate { export interface MaintenanceEntryUpdate {
/** @example "0" */ /** @example "0" */
cost: string; cost: string;
date: string; date: Date;
description: string; description: string;
name: string; name: string;
} }
@ -258,7 +258,7 @@ export interface MaintenanceLog {
itemId: string; itemId: string;
} }
export interface PaginationResultItemSummary { export interface PaginationResultRepoItemSummary {
items: ItemSummary[]; items: ItemSummary[];
page: number; page: number;
pageSize: number; pageSize: number;
@ -302,7 +302,7 @@ export interface ValueOverTime {
} }
export interface ValueOverTimeEntry { export interface ValueOverTimeEntry {
date: string; date: Date;
name: string; name: string;
value: number; value: number;
} }

View file

@ -28,6 +28,7 @@
"vitest": "^0.28.0" "vitest": "^0.28.0"
}, },
"dependencies": { "dependencies": {
"@headlessui/vue": "^1.7.9",
"@iconify/vue": "^3.2.1", "@iconify/vue": "^3.2.1",
"@nuxtjs/tailwindcss": "^6.1.3", "@nuxtjs/tailwindcss": "^6.1.3",
"@pinia/nuxt": "^0.4.1", "@pinia/nuxt": "^0.4.1",

View file

@ -16,7 +16,7 @@
const initialSearch = ref(true); const initialSearch = ref(true);
const api = useUserApi(); const api = useUserApi();
const loading = useMinLoader(2000); const loading = useMinLoader(500);
const items = ref<ItemSummary[]>([]); const items = ref<ItemSummary[]>([]);
const total = ref(0); const total = ref(0);
@ -33,6 +33,7 @@
const query = useRouteQuery("q", ""); const query = useRouteQuery("q", "");
const advanced = useRouteQuery("advanced", false); const advanced = useRouteQuery("advanced", false);
const includeArchived = useRouteQuery("archived", false); const includeArchived = useRouteQuery("archived", false);
const fieldSelector = useRouteQuery("fieldSelector", false);
const totalPages = computed(() => Math.ceil(total.value / pageSize.value)); const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
const hasNext = computed(() => page.value * pageSize.value < total.value); const hasNext = computed(() => page.value * pageSize.value < total.value);
@ -98,6 +99,9 @@
}); });
const locationsStore = useLocationStore(); const locationsStore = useLocationStore();
const locationFlatTree = await useFlatLocations();
const locations = computed(() => locationsStore.allLocations); const locations = computed(() => locationsStore.allLocations);
const labelStore = useLabelStore(); const labelStore = useLabelStore();
@ -147,6 +151,12 @@
return data; return data;
}); });
watch(fieldSelector, (newV, oldV) => {
if (newV === false && oldV === true) {
fieldTuples.value = [];
}
});
async function fetchValues(field: string): Promise<string[]> { async function fetchValues(field: string): Promise<string[]> {
if (fieldValuesCache.value[field]) { if (fieldValuesCache.value[field]) {
return fieldValuesCache.value[field]; return fieldValuesCache.value[field];
@ -198,6 +208,8 @@
} }
} }
const toast = useNotifier();
const { data, error } = await api.items.getAll({ const { data, error } = await api.items.getAll({
q: query.value || "", q: query.value || "",
locations: locIDs.value, locations: locIDs.value,
@ -208,15 +220,21 @@
fields, fields,
}); });
if (error) { function resetItems() {
page.value = Math.max(1, page.value - 1); page.value = Math.max(1, page.value - 1);
loading.value = false; loading.value = false;
total.value = 0;
items.value = [];
}
if (error) {
resetItems();
toast.error("Failed to search items");
return; return;
} }
if (!data.items || data.items.length === 0) { if (!data.items || data.items.length === 0) {
page.value = Math.max(1, page.value - 1); resetItems();
loading.value = false;
return; return;
} }
@ -227,7 +245,7 @@
initialSearch.value = false; initialSearch.value = false;
} }
watchDebounced([page, pageSize, query, advanced], search, { debounce: 250, maxWait: 1000 }); watchDebounced([page, pageSize, query, selectedLabels, selectedLocations], search, { debounce: 250, maxWait: 1000 });
async function submit() { async function submit() {
// Set URL Params // Set URL Params
@ -243,7 +261,8 @@
query: { query: {
// Reactive // Reactive
advanced: "true", advanced: "true",
includeArchived: includeArchived.value ? "true" : "false", archived: includeArchived.value ? "true" : "false",
fieldSelector: fieldSelector.value ? "true" : "false",
pageSize: pageSize.value, pageSize: pageSize.value,
page: page.value, page: page.value,
q: query.value, q: query.value,
@ -261,45 +280,106 @@
// Perform Search // Perform Search
await search(); await search();
} }
async function reset() {
// Set URL Params
const fields = [];
for (const t of fieldTuples.value) {
if (t[0] && t[1]) {
fields.push(`${t[0]}=${t[1]}`);
}
}
await router.push({
query: {
archived: "false",
fieldSelector: "false",
pageSize: 10,
page: 1,
q: "",
loc: [],
lab: [],
fields,
},
});
await search();
}
</script> </script>
<template> <template>
<BaseContainer class="mb-16"> <BaseContainer class="mb-16">
<div v-if="locations && labels">
<div class="flex flex-wrap md:flex-nowrap gap-4 items-end">
<div class="w-full">
<FormTextField v-model="query" placeholder="Search" /> <FormTextField v-model="query" placeholder="Search" />
<div class="text-sm pl-2 pt-2"> <div v-if="byAssetId" class="text-sm pl-2 pt-2">
<p v-if="byAssetId">Querying Asset ID Number: {{ parsedAssetId }}</p> <p>Querying Asset ID Number: {{ parsedAssetId }}</p>
</div> </div>
<div class="flex mt-1"> </div>
<label class="ml-auto label cursor-pointer"> <BaseButton class="btn-block md:w-auto" @click.prevent="submit">
<input v-model="advanced" type="checkbox" class="toggle toggle-primary" /> <template #icon>
<span class="label-text text-base-content ml-2"> Advanced Search </span> <Icon v-if="loading" name="mdi-loading" class="animate-spin" />
<Icon v-else name="mdi-search" />
</template>
Search
</BaseButton>
</div>
<div class="flex flex-wrap md:flex-nowrap gap-2 w-full py-2">
<SearchFilter v-model="selectedLocations" label="Locations" :options="locationFlatTree">
<template #display="{ item }">
<div>
<div class="flex w-full">
{{ item.name }}
</div>
<div v-if="item.name != item.treeString" class="text-xs mt-1">
{{ item.treeString }}
</div>
</div>
</template>
</SearchFilter>
<SearchFilter v-model="selectedLabels" label="Labels" :options="labels" />
<div class="dropdown">
<label tabindex="0" class="btn btn-xs">Options</label>
<div
tabindex="0"
class="dropdown-content mt-1 max-h-72 p-4 w-64 overflow-auto shadow bg-base-100 rounded-md -translate-x-24"
>
<label class="label cursor-pointer mr-auto">
<input v-model="includeArchived" type="checkbox" class="toggle toggle-sm toggle-primary" />
<span class="label-text ml-4"> Include Archived Items </span>
</label> </label>
<label class="label cursor-pointer mr-auto">
<input v-model="fieldSelector" type="checkbox" class="toggle toggle-sm toggle-primary" />
<span class="label-text ml-4"> Field Selector </span>
</label>
<hr class="my-2" />
<BaseButton class="btn-block btn-sm" @click="reset"> Reset Search</BaseButton>
</div> </div>
<BaseCard v-if="advanced" class="my-1 overflow-visible"> </div>
<template #title> Search Tips </template> <div class="dropdown ml-auto dropdown-end">
<template #subtitle> <label tabindex="0" class="btn btn-xs">Tips</label>
<div
tabindex="0"
class="dropdown-content mt-1 p-4 w-[325px] text-sm overflow-auto shadow bg-base-100 rounded-md"
>
<p class="text-base">Search Tips</p>
<ul class="mt-1 list-disc pl-6"> <ul class="mt-1 list-disc pl-6">
<li> <li>
Location and label filters use the 'OR' operation. If more than one is selected only one will be required Location and label filters use the 'OR' operation. If more than one is selected only one will be
for a match. required for a match.
</li> </li>
<li>Searches prefixed with '#'' will query for a asset ID (example '#000-001')</li> <li>Searches prefixed with '#'' will query for a asset ID (example '#000-001')</li>
<li> <li>
Field filters use the 'OR' operation. If more than one is selected only one will be required for a match. Field filters use the 'OR' operation. If more than one is selected only one will be required for a
match.
</li> </li>
</ul> </ul>
</template>
<form class="px-4 pb-4">
<div class="flex pb-2 pt-5">
<label class="label cursor-pointer mr-auto">
<input v-model="includeArchived" type="checkbox" class="toggle toggle-primary" />
<span class="label-text ml-4"> Include Archived Items </span>
</label>
<Spacer />
</div> </div>
<FormMultiselect v-model="selectedLabels" label="Labels" :items="labels ?? []" /> </div>
<FormMultiselect v-model="selectedLocations" label="Locations" :items="locations ?? []" /> </div>
<div class="py-4 space-y-2"> <div v-if="fieldSelector" class="py-4 space-y-2">
<p>Custom Fields</p> <p>Custom Fields</p>
<div v-for="(f, idx) in fieldTuples" :key="idx" class="flex flex-wrap gap-2"> <div v-for="(f, idx) in fieldTuples" :key="idx" class="flex flex-wrap gap-2">
<div class="form-control w-full max-w-xs"> <div class="form-control w-full max-w-xs">
@ -333,11 +413,8 @@
</div> </div>
<BaseButton type="button" class="btn-sm mt-2" @click="() => fieldTuples.push(['', ''])"> Add</BaseButton> <BaseButton type="button" class="btn-sm mt-2" @click="() => fieldTuples.push(['', ''])"> Add</BaseButton>
</div> </div>
<div class="flex justify-end gap-2">
<BaseButton @click.prevent="submit">Search</BaseButton>
</div> </div>
</form>
</BaseCard>
<section class="mt-10"> <section class="mt-10">
<BaseSectionHeader ref="itemsTitle"> Items </BaseSectionHeader> <BaseSectionHeader ref="itemsTitle"> Items </BaseSectionHeader>
<p class="text-base font-medium flex items-center"> <p class="text-base font-medium flex items-center">

View file

@ -2,6 +2,7 @@ lockfileVersion: 5.4
specifiers: specifiers:
'@faker-js/faker': ^7.5.0 '@faker-js/faker': ^7.5.0
'@headlessui/vue': ^1.7.9
'@iconify/vue': ^3.2.1 '@iconify/vue': ^3.2.1
'@nuxtjs/eslint-config-typescript': ^12.0.0 '@nuxtjs/eslint-config-typescript': ^12.0.0
'@nuxtjs/tailwindcss': ^6.1.3 '@nuxtjs/tailwindcss': ^6.1.3
@ -36,6 +37,7 @@ specifiers:
vue-router: '4' vue-router: '4'
dependencies: dependencies:
'@headlessui/vue': 1.7.9_vue@3.2.45
'@iconify/vue': 3.2.1_vue@3.2.45 '@iconify/vue': 3.2.1_vue@3.2.45
'@nuxtjs/tailwindcss': 6.1.3 '@nuxtjs/tailwindcss': 6.1.3
'@pinia/nuxt': 0.4.6_prq2uz4lho2pwp6irk4cfkrxwu '@pinia/nuxt': 0.4.6_prq2uz4lho2pwp6irk4cfkrxwu
@ -736,6 +738,15 @@ packages:
engines: {node: '>=14.0.0', npm: '>=6.0.0'} engines: {node: '>=14.0.0', npm: '>=6.0.0'}
dev: true dev: true
/@headlessui/vue/1.7.9_vue@3.2.45:
resolution: {integrity: sha512-vgLBKszj+m2ozaPOnjWMGnspoLJcU/06vygdEAyAS4nDjp72yA7AYbOIEgdaspUhaMs585ApyiSm3jPTuIxAzg==}
engines: {node: '>=10'}
peerDependencies:
vue: ^3.2.0
dependencies:
vue: 3.2.45
dev: false
/@humanwhocodes/config-array/0.11.7: /@humanwhocodes/config-array/0.11.7:
resolution: {integrity: sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==} resolution: {integrity: sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}