This commit is contained in:
Filipe Dobreira 2024-01-20 12:18:39 -05:00 committed by GitHub
commit 02a80a6d25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 223 additions and 22 deletions

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="z-[999]"> <div v-if="modal" class="z-[999]">
<input :id="modalId" v-model="modal" type="checkbox" class="modal-toggle" /> <input :id="modalId" v-model="modal" type="checkbox" class="modal-toggle" />
<div class="modal modal-bottom sm:modal-middle overflow-visible"> <div class="modal modal-bottom sm:modal-middle overflow-visible">
<div class="modal-box overflow-visible relative"> <div class="modal-box overflow-visible relative">

View file

@ -41,16 +41,10 @@
}, },
}); });
const input = ref<HTMLElement | null>(null); const input = ref<HTMLInputElement | null>(null);
whenever(input, () => {
whenever( input.value!.focus();
() => props.triggerFocus, });
() => {
if (input.value) {
input.value.focus();
}
}
);
const value = useVModel(props, "modelValue"); const value = useVModel(props, "modelValue");
</script> </script>

View file

@ -0,0 +1,34 @@
<template>
<BaseModal v-model="isActive">
<template #title> Quick Search </template>
<div class="flex flex-wrap md:flex-nowrap gap-4 items-end">
<div class="w-full">
<FormTextField v-model="query" placeholder="Search" trigger-focus @keyup.prevent.enter="quickSearch" />
</div>
<BaseButton class="btn-block md:w-auto" @click.prevent="quickSearch">
<template #icon>
<Icon name="mdi-search" />
</template>
Search
</BaseButton>
</div>
</BaseModal>
</template>
<script setup lang="ts">
const query = ref("");
const { isActive } = useQuickSearch();
const router = useRouter();
function quickSearch() {
router.push({
path: "/items",
query: {
q: query.value,
},
});
isActive.value = false;
query.value = "";
}
</script>

View file

@ -0,0 +1,135 @@
import { getRealFormat, isValid } from "gtin";
// Convenient representations of gtin's format values.
enum CodeFormat {
EAN13 = "GTIN-13", // 13-digit EAN
UPCA = "GTIN-12", // 12-digit UPC-A
}
function isSearchShortcut(event: KeyboardEvent): boolean {
return (event.metaKey || event.ctrlKey) && event.key === "f";
}
/**
* For our purposes, a valid barcode fragment is any digit, with no
* standard modifiers enabled (shift, ctrl, meta):
*/
function isCodeFragment(event: KeyboardEvent): boolean {
return !Number.isNaN(parseInt(event.key)) && !event.ctrlKey && !event.metaKey && !event.shiftKey;
}
/**
* Performs a check to ensure something is a EAN13 or UPC-A code.
*/
function isValidCode(code: string): boolean {
try {
// We check the format in advance, since we're only accepting EAN13 and UPC-A here.
// There's no -real- reason for this limitation, besides protecting against feature
// creep from supporting a larger number of formats.
const realFormat = getRealFormat(code);
return realFormat === CodeFormat.EAN13 || (realFormat === CodeFormat.UPCA && isValid(code));
} catch (err) {
// gtin.getRealFormat will throw on codes that are absolutely invalid,
// as opposed to returning false for codes that look correct but fail
// a format-specific check.
return false;
}
}
/**
* Ignore code input if the user is actually trying to write anywhere:
*/
function isEventTargetInput(event: KeyboardEvent) {
const tagName = (event.target as HTMLElement).tagName;
return tagName != null && tagName.toUpperCase() === "INPUT";
}
/**
* Provides utilities to:
*
* - Quickly open a global search utility, that forwards to the item page.
* - Transparently listens for UPC/EAN-like barcode input, and automatically
* generates an appropriate search query for valid inputs; this allows consumer
* USB barcode scanners to be used as a quick shortcut.
*/
export function useQuickSearch() {
const router = useRouter();
/**
* Tracks if the quick search dialog is active.
*
* We 'steal' cmd/ctrl+F from the user, but allow them to go back to
* the browser-default search by tapping the shortcut twice.
*/
const isActive = ref(false);
/**
* codeBuffer acts as intermediate buffer state for a partial
* EAN/UPC code.
*/
const codeBuffer = ref("");
function onKeyDown(event: KeyboardEvent) {
if (isSearchShortcut(event)) {
// If quick search is already active, and the user taps cmd+F, get it out of the way
// and allow the default browser search to kick in:
if (isActive.value) {
isActive.value = false;
} else {
isActive.value = true;
event.preventDefault();
}
} else if (isCodeFragment(event) && !isEventTargetInput(event)) {
const fragment = event.key;
// Push this code fragment into our buffer. At this point we also
// ensure we have no more than 13 numbers in the buffer, and if that
// is the case, we clear it ahead of starting what we assume to be
// a new code:
if (codeBuffer.value.length < 13) {
codeBuffer.value = `${codeBuffer.value}${fragment}`;
} else {
// Reset the buffer:
codeBuffer.value = fragment;
}
} else if (event.key === "Enter" && isValidCode(codeBuffer.value)) {
// If we have an active code buffer that seems valid, and the user presses Enter,
// we want to generate a new search query from this code, as long as it seems valid.
//
// Consumer/most(?) USB barcode scanners will terminate valid codes with an Enter key press,
// which is what we're reacting to here.
const validCode = codeBuffer.value;
// Regardless of what we do next, we also clear the code buffer here:
codeBuffer.value = "";
// TODO: Is there a good reason to not expose custom fields via search syntax?
router.push({
path: "/items",
query: {
q: "",
fieldSelector: "true",
// TODO: Barcode= is a temporary approach to support this behavior.
fields: [encodeURIComponent(`Barcode=${validCode}`)],
},
});
} else {
// Every other key press resets the buffer - this applies to non-code values,
// and also to pressing the Enter key without a valid code;
codeBuffer.value = "";
}
}
onMounted(() => {
window.addEventListener("keydown", onKeyDown);
});
onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown);
});
return {
isActive,
};
}

View file

@ -9,20 +9,21 @@
<ItemCreateModal v-model="modals.item" /> <ItemCreateModal v-model="modals.item" />
<LabelCreateModal v-model="modals.label" /> <LabelCreateModal v-model="modals.label" />
<LocationCreateModal v-model="modals.location" /> <LocationCreateModal v-model="modals.location" />
<QuickSearch />
<AppToast /> <AppToast />
<div class="drawer drawer-mobile"> <div class="drawer drawer-mobile">
<input id="my-drawer-2" v-model="drawerToggle" type="checkbox" class="drawer-toggle" /> <input id="nav-drawer" v-model="drawerToggle" type="checkbox" class="drawer-toggle" />
<div class="drawer-content justify-center bg-base-300 pt-20 lg:pt-0"> <div class="drawer-content justify-center bg-base-300 pt-20 lg:pt-0">
<AppHeaderDecor class="-mt-10 hidden lg:block" /> <AppHeaderDecor class="-mt-10 hidden lg:block" />
<!-- Button --> <!-- Button -->
<div class="navbar z-[99] lg:hidden top-0 fixed bg-primary shadow-md drawer-button"> <div class="navbar z-[99] lg:hidden top-0 fixed bg-primary shadow-md drawer-button">
<label for="my-drawer-2" class="btn btn-square btn-ghost text-base-100 drawer-button lg:hidden"> <label for="nav-drawer" class="btn btn-square btn-ghost text-base-100 drawer-button lg:hidden">
<Icon name="mdi-menu" class="h-6 w-6" /> <Icon name="mdi-menu" class="h-6 w-6" />
</label> </label>
<NuxtLink to="/home"> <NuxtLink to="/home">
<h2 class="text-3xl font-bold tracking-tight text-base-100 flex"> <h2 class="text-3xl font-bold tracking-tight text-base-100 flex flex-row items-end">
HomeB HomeB
<AppLogo class="w-8 -mb-3" /> <AppLogo class="w-8" />
x x
</h2> </h2>
</NuxtLink> </NuxtLink>
@ -33,7 +34,7 @@
<!-- Sidebar --> <!-- Sidebar -->
<div class="drawer-side shadow-lg"> <div class="drawer-side shadow-lg">
<label for="my-drawer-2" class="drawer-overlay"></label> <label for="nav-drawer" class="drawer-overlay"></label>
<!-- Top Section --> <!-- Top Section -->
<div class="w-60 py-5 md:py-10 bg-base-200 flex flex-grow-1 flex-col"> <div class="w-60 py-5 md:py-10 bg-base-200 flex flex-grow-1 flex-col">

View file

@ -46,6 +46,7 @@
"autoprefixer": "^10.4.8", "autoprefixer": "^10.4.8",
"daisyui": "^2.24.0", "daisyui": "^2.24.0",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
"gtin": "^1.0.2",
"h3": "^1.7.1", "h3": "^1.7.1",
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",

View file

@ -74,9 +74,17 @@
queryParamsInitialized.value = true; queryParamsInitialized.value = true;
searchLocked.value = false; searchLocked.value = false;
const qFields = route.query.fields as string[]; const qFields = route.query.fields as string[] | string;
if (qFields) { if (qFields != null) {
fieldTuples.value = qFields.map(f => f.split("=") as [string, string]); // Ensure qFields are represented as an array of string, as expected:
const qFieldsAsArray = Array.isArray(qFields) ? qFields : [qFields];
const parsedFieldTuples = qFieldsAsArray.map(f => decodeURIComponent(f).split("=") as [string, string]);
// After loading for the first time, we keep track of the field tuples at this point,
// and use them as possible field values even if they're not included in the list of values for
// the given field:
fieldTuples.value = parsedFieldTuples;
fieldTuplesOnMount.value = parsedFieldTuples;
for (const t of fieldTuples.value) { for (const t of fieldTuples.value) {
if (t[0] && t[1]) { if (t[0] && t[1]) {
@ -139,6 +147,7 @@
}); });
const fieldTuples = ref<[string, string][]>([]); const fieldTuples = ref<[string, string][]>([]);
const fieldTuplesOnMount = ref<[string, string][]>([]);
const fieldValuesCache = ref<Record<string, string[]>>({}); const fieldValuesCache = ref<Record<string, string[]>>({});
const { data: allFields } = useAsyncData(async () => { const { data: allFields } = useAsyncData(async () => {
@ -173,6 +182,22 @@
return data; return data;
} }
/**
* Temporary approach to arbitrary field values, which merges search query input
* into the values cache for a given field.
*
* Since this relies on `onMounted`, and due to how search works, it's less than
* ideal, and supremely confusing when a value you saw 2 seconds ago suddenly disappears.
*/
function fieldValuesFromCache(field: string) {
const fieldValues = fieldValuesCache.value[field] ?? [];
const valuesFromFirstMount = fieldTuplesOnMount.value
.filter(([fieldName]) => fieldName === field)
.map(([_, fieldValue]) => fieldValue);
return [...new Set([...valuesFromFirstMount, ...fieldValues])];
}
watch(advanced, (v, lv) => { watch(advanced, (v, lv) => {
if (v === false && lv === true) { if (v === false && lv === true) {
selectedLocations.value = []; selectedLocations.value = [];
@ -245,7 +270,10 @@
initialSearch.value = false; initialSearch.value = false;
} }
watchDebounced([page, pageSize, query, selectedLabels, selectedLocations], 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
@ -399,8 +427,8 @@
<label class="label"> <label class="label">
<span class="label-text">Field Value</span> <span class="label-text">Field Value</span>
</label> </label>
<select v-model="fieldTuples[idx][1]" class="select-bordered select" :items="fieldValuesCache[f[0]]"> <select v-model="fieldTuples[idx][1]" class="select-bordered select" :items="fieldValuesFromCache(f[0])">
<option v-for="v in fieldValuesCache[f[0]]" :key="v" :value="v">{{ v }}</option> <option v-for="v in fieldValuesFromCache(f[0])" :key="v" :value="v">{{ v }}</option>
</select> </select>
</div> </div>
<button <button

View file

@ -41,6 +41,9 @@ dependencies:
dompurify: dompurify:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
gtin:
specifier: ^1.0.2
version: 1.0.2
h3: h3:
specifier: ^1.7.1 specifier: ^1.7.1
version: 1.7.1 version: 1.7.1
@ -5758,6 +5761,11 @@ packages:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
dev: true dev: true
/gtin@1.0.2:
resolution: {integrity: sha512-jEsHMz16c3yz0rlM4TvUUU0022FTniIAcBfCDoch+38RJC32yGkdKFC9ixpBqPskYpCRrb614AjF8O0QQP0gPg==}
engines: {node: '>=10'}
dev: false
/gzip-size@7.0.0: /gzip-size@7.0.0:
resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}