feat: allow nested relationships for locations and items (#102)

Basic implementation that allows organizing Locations and Items within each other.
This commit is contained in:
Hayden 2022-10-23 20:54:39 -08:00 committed by GitHub
parent fe6cd431a6
commit a4b4fe3454
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 2329 additions and 126 deletions

View file

@ -3,7 +3,6 @@
v-if="to"
v-bind="attributes"
ref="submitBtn"
:to="to"
class="btn"
:class="{
loading: loading,
@ -64,7 +63,7 @@
const attributes = computed(() => {
if (props.to) {
return {
href: props.to,
to: props.to,
};
}
return {

View 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: ItemsObject[] | string[];
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>

View file

@ -45,6 +45,10 @@
type: String,
default: "",
},
compareKey: {
type: String,
default: null,
},
});
const selectedIdx = ref(-1);
@ -52,36 +56,34 @@
const internalSelected = useVModel(props, "modelValue", emit);
const internalValue = useVModel(props, "value", emit);
watch(selectedIdx, newVal => {
internalSelected.value = props.items[newVal];
});
watch(selectedIdx, newVal => {
if (props.valueKey) {
internalValue.value = props.items[newVal][props.valueKey];
}
});
watch(
internalSelected,
() => {
const idx = props.items.findIndex(item => compare(item, internalSelected.value));
selectedIdx.value = idx;
selectedIdx,
newVal => {
if (newVal === -1) {
return;
}
if (props.value) {
internalValue.value = props.items[newVal][props.valueKey];
}
internalSelected.value = props.items[newVal];
},
{
immediate: true,
}
{ immediate: true }
);
watch(
internalValue,
[internalSelected, () => props.value],
() => {
const idx = props.items.findIndex(item => compare(item[props.valueKey], internalValue.value));
if (props.valueKey) {
const idx = props.items.findIndex(item => compare(item, internalValue.value));
selectedIdx.value = idx;
return;
}
const idx = props.items.findIndex(item => compare(item, internalSelected.value));
selectedIdx.value = idx;
},
{
immediate: true,
}
{ immediate: true }
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -90,8 +92,13 @@
return true;
}
if (!a || !b) {
return false;
if (props.valueKey) {
return a[props.valueKey] === b;
}
// Try compare key
if (props.compareKey && a && b) {
return a[props.compareKey] === b[props.compareKey];
}
return JSON.stringify(a) === JSON.stringify(b);

View file

@ -17,20 +17,18 @@
<Icon name="heroicons-map-pin" class="swap-off" />
</label>
{{ location.name }}
<span v-if="location.itemCount" class="badge badge-secondary badge-lg ml-auto text-secondary-content">
{{ location.itemCount }}</span
>
<span v-if="hasCount" class="badge badge-secondary badge-lg ml-auto text-secondary-content"> {{ count }}</span>
</h2>
</div>
</NuxtLink>
</template>
<script lang="ts" setup>
import { LocationOutCount } from "~~/lib/api/types/data-contracts";
import { LocationOut, LocationOutCount, LocationSummary } from "~~/lib/api/types/data-contracts";
defineProps({
const props = defineProps({
location: {
type: Object as () => LocationOutCount,
type: Object as () => LocationOutCount | LocationOut | LocationSummary,
required: true,
},
dense: {
@ -39,6 +37,16 @@
},
});
const hasCount = computed(() => {
return !!(props.location as LocationOutCount).itemCount;
});
const count = computed(() => {
if (hasCount.value) {
return (props.location as LocationOutCount).itemCount;
}
});
const card = ref(null);
const isHover = useElementHover(card);
const { focused } = useFocus(card);

View 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,
};
}

View file

@ -1,9 +1,7 @@
import { BaseAPI, route } from "../base";
import { LocationOutCount, LocationCreate, LocationOut } from "../types/data-contracts";
import { LocationOutCount, LocationCreate, LocationOut, LocationUpdate } from "../types/data-contracts";
import { Results } from "../types/non-generated";
export type LocationUpdate = LocationCreate;
export class LocationsApi extends BaseAPI {
getAll() {
return this.http.get<Results<LocationOutCount>>({ url: route("/locations") });

View file

@ -49,6 +49,7 @@ export interface ItemCreate {
/** Edges */
locationId: string;
name: string;
parentId: string | null;
}
export interface ItemField {
@ -63,10 +64,9 @@ export interface ItemField {
export interface ItemOut {
attachments: ItemAttachment[];
children: ItemSummary[];
createdAt: Date;
description: string;
/** Future */
fields: ItemField[];
id: string;
insured: boolean;
@ -76,13 +76,14 @@ export interface ItemOut {
lifetimeWarranty: boolean;
/** Edges */
location: LocationSummary;
location: LocationSummary | null;
manufacturer: string;
modelNumber: string;
name: string;
/** Extras */
notes: string;
parent: ItemSummary | null;
purchaseFrom: string;
/** @example 0 */
@ -113,7 +114,7 @@ export interface ItemSummary {
labels: LabelSummary[];
/** Edges */
location: LocationSummary;
location: LocationSummary | null;
name: string;
quantity: number;
updatedAt: Date;
@ -137,6 +138,7 @@ export interface ItemUpdate {
/** Extras */
notes: string;
parentId: string | null;
purchaseFrom: string;
/** @example 0 */
@ -189,11 +191,13 @@ export interface LocationCreate {
}
export interface LocationOut {
children: LocationSummary[];
createdAt: Date;
description: string;
id: string;
items: ItemSummary[];
name: string;
parent: LocationSummary;
updatedAt: Date;
}
@ -214,6 +218,13 @@ export interface LocationSummary {
updatedAt: Date;
}
export interface LocationUpdate {
description: string;
id: string;
name: string;
parentId: string | null;
}
export interface PaginationResultRepoItemSummary {
items: ItemSummary[];
page: number;

View file

@ -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,8 +38,13 @@
}
}
if (data.parent) {
parent.value = data.parent;
}
return data;
});
onMounted(() => {
refresh();
});
@ -48,6 +54,7 @@
...item.value,
locationId: item.value.location?.id,
labelIds: item.value.labels.map(l => l.id),
parentId: parent.value ? parent.value.id : null,
};
const { error } = await api.items.update(itemId.value, payload);
@ -256,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,
@ -306,6 +312,9 @@
timeValue: null,
});
}
const { query, results } = useItemSearch(api, { immediate: false });
const parent = ref();
</script>
<template>
@ -314,8 +323,8 @@
<template #title> Attachment Edit </template>
<FormTextField v-model="editState.title" label="Attachment Title" />
{{ editState.type }}
<FormSelect
v-model="editState.obj"
v-model:value="editState.type"
label="Attachment Type"
value-key="value"
@ -354,8 +363,24 @@
</template>
</BaseSectionHeader>
<div class="px-5 mb-6 grid md:grid-cols-2 gap-4">
<FormSelect v-if="item" v-model="item.location" label="Location" :items="locations ?? []" />
<FormSelect
v-if="item"
v-model="item.location"
label="Location"
:items="locations ?? []"
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">

View file

@ -76,6 +76,10 @@
name: "Description",
text: item.value?.description,
},
{
name: "Quantity",
text: item.value?.quantity,
},
{
name: "Serial Number",
text: item.value?.serialNumber,
@ -287,12 +291,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>
@ -378,5 +393,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>

View file

@ -1,5 +1,7 @@
<script setup lang="ts">
import { Detail, CustomDetail } from "~~/components/global/DetailsSection/types";
import { LocationSummary, LocationUpdate } from "~~/lib/api/types/data-contracts";
import { useLocationStore } from "~~/stores/locations";
definePageMeta({
middleware: ["auth"],
@ -20,6 +22,11 @@
navigateTo("/home");
return;
}
if (data.parent) {
parent.value = locations.value.find(l => l.id === data.parent.id);
}
return data;
});
@ -62,7 +69,7 @@
async function confirmDelete() {
const { isCanceled } = await confirm.open(
"Are you sure you want to delete this location? This action cannot be undone."
"Are you sure you want to delete this location and all of its items? This action cannot be undone."
);
if (isCanceled) {
return;
@ -80,9 +87,11 @@
const updateModal = ref(false);
const updating = ref(false);
const updateData = reactive({
const updateData = reactive<LocationUpdate>({
id: locationId.value,
name: "",
description: "",
parentId: null,
});
function openUpdate() {
@ -93,6 +102,7 @@
async function update() {
updating.value = true;
updateData.parentId = parent.value?.id || null;
const { error, data } = await api.locations.update(locationId.value, updateData);
if (error) {
@ -105,6 +115,12 @@
updateModal.value = false;
updating.value = false;
}
const locationStore = useLocationStore();
const locations = computed(() => locationStore.locations);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parent = ref<LocationSummary | any>({});
</script>
<template>
@ -114,6 +130,7 @@
<form v-if="location" @submit.prevent="update">
<FormTextField v-model="updateData.name" :autofocus="true" label="Location Name" />
<FormTextArea v-model="updateData.description" label="Location Description" />
<FormAutocomplete v-model="parent" :items="locations" item-text="name" item-value="id" label="Parent" />
<div class="modal-action">
<BaseButton type="submit" :loading="updating"> Update </BaseButton>
</div>
@ -127,6 +144,14 @@
<span class="text-base-content">
{{ location ? location.name : "" }}
</span>
<div v-if="location && location.parent" class="text-sm breadcrumbs pb-0">
<ul class="text-base-content/70">
<li>
<NuxtLink :to="`/location/${location.parent.id}`"> {{ location.parent.name }}</NuxtLink>
</li>
<li>{{ location.name }}</li>
</ul>
</div>
</BaseSectionHeader>
</template>
@ -152,11 +177,18 @@
<DetailsSection :details="details" />
</BaseCard>
<section v-if="location">
<section v-if="location && location.items.length > 0">
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
<div class="grid gap-2 grid-cols-1 sm:grid-cols-2">
<ItemCard v-for="item in location.items" :key="item.id" :item="item" />
</div>
</section>
<section v-if="location && location.children.length > 0">
<BaseSectionHeader class="mb-5"> Child Locations </BaseSectionHeader>
<div class="grid gap-2 grid-cols-1 sm:grid-cols-3">
<LocationCard v-for="item in location.children" :key="item.id" :location="item" />
</div>
</section>
</BaseContainer>
</template>