expanded search for items

This commit is contained in:
Hayden 2022-10-12 16:23:12 -08:00
parent 1b20a69c5e
commit 9e830610a2
17 changed files with 322 additions and 25 deletions

View file

@ -70,6 +70,34 @@ const docTemplate = `{
"Items" "Items"
], ],
"summary": "Get All Items", "summary": "Get All Items",
"parameters": [
{
"type": "string",
"description": "search string",
"name": "q",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "label Ids",
"name": "labels",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "location Ids",
"name": "locations",
"in": "query"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",

View file

@ -62,6 +62,34 @@
"Items" "Items"
], ],
"summary": "Get All Items", "summary": "Get All Items",
"parameters": [
{
"type": "string",
"description": "search string",
"name": "q",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "label Ids",
"name": "labels",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "location Ids",
"name": "locations",
"in": "query"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",

View file

@ -426,6 +426,25 @@ paths:
- User - User
/v1/items: /v1/items:
get: get:
parameters:
- description: search string
in: query
name: q
type: string
- collectionFormat: multi
description: label Ids
in: query
items:
type: string
name: labels
type: array
- collectionFormat: multi
description: location Ids
in: query
items:
type: string
name: locations
type: array
produces: produces:
- application/json - application/json
responses: responses:

View file

@ -3,24 +3,51 @@ package v1
import ( import (
"encoding/csv" "encoding/csv"
"net/http" "net/http"
"net/url"
"github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/repo"
"github.com/hay-kot/homebox/backend/internal/services" "github.com/hay-kot/homebox/backend/internal/services"
"github.com/hay-kot/homebox/backend/pkgs/server" "github.com/hay-kot/homebox/backend/pkgs/server"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func uuidList(params url.Values, key string) []uuid.UUID {
var ids []uuid.UUID
for _, id := range params[key] {
uid, err := uuid.Parse(id)
if err != nil {
continue
}
ids = append(ids, uid)
}
return ids
}
func extractQuery(r *http.Request) repo.ItemQuery {
params := r.URL.Query()
return repo.ItemQuery{
Search: params.Get("q"),
LocationIDs: uuidList(params, "locations"),
LabelIDs: uuidList(params, "labels"),
}
}
// HandleItemsGetAll godoc // HandleItemsGetAll godoc
// @Summary Get All Items // @Summary Get All Items
// @Tags Items // @Tags Items
// @Produce json // @Produce json
// @Param q query string false "search string"
// @Param labels query []string false "label Ids" collectionFormat(multi)
// @Param locations query []string false "location Ids" collectionFormat(multi)
// @Success 200 {object} server.Results{items=[]repo.ItemSummary} // @Success 200 {object} server.Results{items=[]repo.ItemSummary}
// @Router /v1/items [GET] // @Router /v1/items [GET]
// @Security Bearer // @Security Bearer
func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc { func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user := services.UseUserCtx(r.Context()) ctx := services.NewContext(r.Context())
items, err := ctrl.svc.Items.GetAll(r.Context(), user.GroupID) items, err := ctrl.svc.Items.Query(ctx, extractQuery(r))
if err != nil { if err != nil {
log.Err(err).Msg("failed to get items") log.Err(err).Msg("failed to get items")
server.RespondServerError(w) server.RespondServerError(w)

View file

@ -8,6 +8,8 @@ import (
"github.com/hay-kot/homebox/backend/ent" "github.com/hay-kot/homebox/backend/ent"
"github.com/hay-kot/homebox/backend/ent/group" "github.com/hay-kot/homebox/backend/ent/group"
"github.com/hay-kot/homebox/backend/ent/item" "github.com/hay-kot/homebox/backend/ent/item"
"github.com/hay-kot/homebox/backend/ent/label"
"github.com/hay-kot/homebox/backend/ent/location"
"github.com/hay-kot/homebox/backend/ent/predicate" "github.com/hay-kot/homebox/backend/ent/predicate"
) )
@ -16,6 +18,12 @@ type ItemsRepository struct {
} }
type ( type (
ItemQuery struct {
Search string `json:"search"`
LocationIDs []uuid.UUID `json:"locationIds"`
LabelIDs []uuid.UUID `json:"labelIds"`
}
ItemCreate struct { ItemCreate struct {
ImportRef string `json:"-"` ImportRef string `json:"-"`
Name string `json:"name"` Name string `json:"name"`
@ -206,6 +214,40 @@ func (e *ItemsRepository) GetOneByGroup(ctx context.Context, gid, id uuid.UUID)
return e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid))) return e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid)))
} }
// QueryByGroup returns a list of items that belong to a specific group based on the provided query.
func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q ItemQuery) ([]ItemSummary, error) {
qb := e.db.Item.Query().Where(item.HasGroupWith(group.ID(gid)))
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 != "" {
qb.Where(
item.Or(
item.NameContainsFold(q.Search),
item.DescriptionContainsFold(q.Search),
),
)
}
return mapItemsSummaryErr(qb.WithLabel().
WithLocation().
All(ctx))
}
// GetAll returns all the items in the database with the Labels and Locations eager loaded. // GetAll returns all the items in the database with the Labels and Locations eager loaded.
func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSummary, error) { func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSummary, error) {
return mapItemsSummaryErr(e.db.Item.Query(). return mapItemsSummaryErr(e.db.Item.Query().

View file

@ -28,6 +28,10 @@ func (svc *ItemService) GetOne(ctx context.Context, gid uuid.UUID, id uuid.UUID)
return svc.repo.Items.GetOneByGroup(ctx, gid, id) return svc.repo.Items.GetOneByGroup(ctx, gid, id)
} }
func (svc *ItemService) Query(ctx Context, q repo.ItemQuery) ([]repo.ItemSummary, error) {
return svc.repo.Items.QueryByGroup(ctx, ctx.GID, q)
}
func (svc *ItemService) GetAll(ctx context.Context, gid uuid.UUID) ([]repo.ItemSummary, error) { func (svc *ItemService) GetAll(ctx context.Context, gid uuid.UUID) ([]repo.ItemSummary, error) {
return svc.repo.Items.GetAll(ctx, gid) return svc.repo.Items.GetAll(ctx, gid)
} }

View file

@ -18,6 +18,10 @@
name: "Home", name: "Home",
href: "/home", href: "/home",
}, },
{
name: "Items",
href: "/items",
},
{ {
name: "Logout", name: "Logout",
action: logout, action: logout,

View file

@ -11,7 +11,8 @@
</div> </div>
<ul <ul
tabindex="0" tabindex="0"
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 scroll-bar" 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 <li
v-for="(obj, idx) in items" v-for="(obj, idx) in items"

View file

@ -3,13 +3,13 @@
<label class="label"> <label class="label">
<span class="label-text">{{ label }}</span> <span class="label-text">{{ label }}</span>
</label> </label>
<input ref="input" v-model="value" :type="type" class="input input-bordered w-full" /> <input ref="input" v-model="value" :placeholder="placeholder" :type="type" class="input input-bordered w-full" />
</div> </div>
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4"> <div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
<label class="label"> <label class="label">
<span class="label-text">{{ label }}</span> <span class="label-text">{{ label }}</span>
</label> </label>
<input v-model="value" class="input input-bordered col-span-3 w-full mt-2" /> <input v-model="value" :placeholder="placeholder" class="input input-bordered col-span-3 w-full mt-2" />
</div> </div>
</template> </template>
@ -35,6 +35,10 @@
type: Boolean, type: Boolean,
default: false, default: false,
}, },
placeholder: {
type: String,
default: "",
},
}); });
const input = ref<HTMLElement | null>(null); const input = ref<HTMLElement | null>(null);

View file

@ -0,0 +1,32 @@
import { WritableComputedRef } from "vue";
export function useMinLoader(ms = 500): WritableComputedRef<boolean> {
const loading = ref(false);
const locked = ref(false);
const minLoading = computed({
get: () => loading.value,
set: value => {
if (value) {
loading.value = true;
if (!locked.value) {
locked.value = true;
setTimeout(() => {
locked.value = false;
}, ms);
}
}
if (!value && !locked.value) {
loading.value = false;
} else if (!value && locked.value) {
setTimeout(() => {
loading.value = false;
}, ms);
}
},
});
return minLoading;
}

View file

@ -8,12 +8,17 @@ import {
ItemSummary, ItemSummary,
ItemUpdate, ItemUpdate,
} from "../types/data-contracts"; } from "../types/data-contracts";
import { AttachmentTypes } from "../types/non-generated"; import { AttachmentTypes, Results } from "../types/non-generated";
import { Results } from "./types";
export type ItemsQuery = {
locations?: string[];
labels?: string[];
q?: string;
};
export class ItemsApi extends BaseAPI { export class ItemsApi extends BaseAPI {
getAll() { getAll(q: ItemsQuery = {}) {
return this.http.get<Results<ItemSummary>>({ url: route("/items") }); return this.http.get<Results<ItemSummary>>({ url: route("/items", q) });
} }
create(item: ItemCreate) { create(item: ItemCreate) {

View file

@ -1,6 +1,6 @@
import { BaseAPI, route } from "../base"; import { BaseAPI, route } from "../base";
import { LabelCreate, LabelOut } from "../types/data-contracts"; import { LabelCreate, LabelOut } from "../types/data-contracts";
import { Results } from "./types"; import { Results } from "../types/non-generated";
export class LabelsApi extends BaseAPI { export class LabelsApi extends BaseAPI {
getAll() { getAll() {

View file

@ -1,6 +1,6 @@
import { BaseAPI, route } from "../base"; import { BaseAPI, route } from "../base";
import { LocationOutCount, LocationCreate, LocationOut } from "../types/data-contracts"; import { LocationOutCount, LocationCreate, LocationOut } from "../types/data-contracts";
import { Results } from "./types"; import { Results } from "../types/non-generated";
export type LocationUpdate = LocationCreate; export type LocationUpdate = LocationCreate;

View file

@ -1,3 +0,0 @@
export type Results<T> = {
items: T[];
};

View file

@ -8,3 +8,7 @@ export enum AttachmentTypes {
export type Result<T> = { export type Result<T> = {
item: T; item: T;
}; };
export type Results<T> = {
items: T[];
};

View file

@ -157,13 +157,6 @@
</BaseCard> </BaseCard>
</section> </section>
<section>
<BaseSectionHeader class="mb-5"> Labels </BaseSectionHeader>
<div class="flex gap-2 flex-wrap">
<LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" />
</div>
</section>
<section> <section>
<BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader> <BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4">
@ -172,9 +165,9 @@
</section> </section>
<section> <section>
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader> <BaseSectionHeader class="mb-5"> Labels </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="flex gap-2 flex-wrap">
<ItemCard v-for="item in items" :key="item.id" :item="item" /> <LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" />
</div> </div>
</section> </section>
</BaseContainer> </BaseContainer>

109
frontend/pages/items.vue Normal file
View file

@ -0,0 +1,109 @@
<script setup lang="ts">
import { ItemSummary } from "~~/lib/api/types/data-contracts";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
definePageMeta({
layout: "home",
});
useHead({
title: "Homebox | Home",
});
const api = useUserApi();
const query = ref("");
const loading = useMinLoader(2000);
const results = ref<ItemSummary[]>([]);
async function search() {
loading.value = true;
const locations = selectedLocations.value.map(l => l.id);
const labels = selectedLabels.value.map(l => l.id);
const { data, error } = await api.items.getAll({ q: query.value, locations, labels });
if (error) {
loading.value = false;
return;
}
results.value = data.items;
loading.value = false;
}
onMounted(() => {
search();
});
const locationsStore = useLocationStore();
const locations = computed(() => locationsStore.locations);
const labelStore = useLabelStore();
const labels = computed(() => labelStore.labels);
const advanced = ref(false);
const selectedLocations = ref([]);
const selectedLabels = ref([]);
watchEffect(() => {
if (!advanced.value) {
selectedLocations.value = [];
selectedLabels.value = [];
}
});
watchDebounced(query, search, { debounce: 250, maxWait: 1000 });
watchDebounced(selectedLocations, search, { debounce: 250, maxWait: 1000 });
watchDebounced(selectedLabels, search, { debounce: 250, maxWait: 1000 });
</script>
<template>
<BaseContainer class="mb-16">
<FormTextField v-model="query" placeholder="Search" />
<div class="flex mt-1">
<label class="ml-auto label cursor-pointer">
<input v-model="advanced" type="checkbox" class="toggle toggle-primary" />
<span class="label-text text-neutral-content ml-2"> Filters </span>
</label>
</div>
<BaseCard v-if="advanced" class="my-1 overflow-visible">
<template #title> Filters </template>
<template #subtitle>
Location and label filters use the 'OR' operation. If more than one is selected only one will be required for a
match
</template>
<div class="px-4 pb-4">
<FormMultiselect v-model="selectedLabels" label="Labels" :items="labels ?? []" />
<FormMultiselect v-model="selectedLocations" label="Labels" :items="locations ?? []" />
</div>
</BaseCard>
<section class="mt-10">
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<TransitionGroup name="list">
<ItemCard v-for="item in results" :key="item.id" :item="item" />
</TransitionGroup>
<div class="hidden first:inline text-xl">No Items Found</div>
</div>
</section>
</BaseContainer>
</template>
<style lang="css">
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.25s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-leave-active {
position: absolute;
}
</style>