Kiosk drive filter and wider picker
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -24,6 +24,12 @@ ADR-007 and ADR-002.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Muted text on the USB kiosk and in guides was not muted.** Both named a
|
||||
`--textmuted` variable this app has never defined, so the browser dropped the
|
||||
declaration and the hints, subtitles and locker lines rendered at full
|
||||
strength against the text they were meant to sit behind. The variable is
|
||||
`--text-light`.
|
||||
|
||||
- **Plugin reports rendered a raw JSON dump.** The reports page ran a hardcoded
|
||||
switch over five core report ids and fell through to
|
||||
`console.error('Unknown report')`, and its results panel was a matching chain
|
||||
@@ -77,6 +83,13 @@ ADR-007 and ADR-002.
|
||||
left behind by somebody on leave would then be unreturnable at the kiosk,
|
||||
which just moves the problem to a person with a login.
|
||||
|
||||
The list of free drives is filtered from a search box, and lays itself out in
|
||||
as many columns as the panel is wide - up to four on a wall display. A cabinet
|
||||
of thirty drives was one long column somebody had to read through. A drive is
|
||||
found by its id, by what is written on it, or by where it lives, in whatever
|
||||
word order comes naturally: "locker engineer" finds the engineering locker,
|
||||
because that is what people say.
|
||||
|
||||
- **Audit log retention.** The audit log is append-only and nothing ever removed
|
||||
a row, so it grew for as long as a site ran - here to the point where the
|
||||
audit page was tens of thousands of pages deep and therefore useless, because
|
||||
|
||||
@@ -253,7 +253,7 @@ function select (block) {
|
||||
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
|
||||
border-radius: 4px; font-size: 0.9rem;
|
||||
}
|
||||
.gb-imageloading { color: var(--textmuted); }
|
||||
.gb-imageloading { color: var(--text-light); }
|
||||
.gb-imagefailed { color: var(--danger, #b3261e); }
|
||||
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
|
||||
|
||||
|
||||
29
plugins/usb/frontend/drivefilter.js
Normal file
29
plugins/usb/frontend/drivefilter.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Narrowing a cabinet of drives from a kiosk keyboard.
|
||||
//
|
||||
// Every free drive is already on the client, so this filters what is on screen
|
||||
// rather than asking the API again - the list is small, and a round trip per
|
||||
// keystroke on a wall panel is a worse experience than no filter at all.
|
||||
|
||||
// What one drive can be found by. 'locker' is in here on purpose: it is how
|
||||
// people say where a drive lives, so "locker engineer" has to find the drives
|
||||
// in the engineering locker rather than nothing at all.
|
||||
export function drivetext (drive) {
|
||||
return [drive.device_id, drive.device_desc, 'locker', drive.locker_location]
|
||||
.filter(Boolean).join(' ').toLowerCase()
|
||||
}
|
||||
|
||||
// Every word typed has to appear somewhere in the drive's text, in any order,
|
||||
// so "engineer kingston" and "kingston engineer" find the same drive. An empty
|
||||
// query matches everything - a blank box is not a filter.
|
||||
export function matchesdrive (drive, query) {
|
||||
const words = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (!words.length) return true
|
||||
const text = drivetext(drive)
|
||||
return words.every(word => text.includes(word))
|
||||
}
|
||||
|
||||
export function filterdrives (drives, query) {
|
||||
const words = String(query || '').trim()
|
||||
if (!words) return drives
|
||||
return drives.filter(drive => matchesdrive(drive, query))
|
||||
}
|
||||
62
plugins/usb/frontend/drivefilter.spec.js
Normal file
62
plugins/usb/frontend/drivefilter.spec.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// The kiosk filter. Every case here is something somebody standing at the
|
||||
// cabinet would actually type, including the phrasing they use out loud -
|
||||
// "locker engineer" rather than the bare locker name.
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { filterdrives, matchesdrive } from './drivefilter'
|
||||
|
||||
const DRIVES = [
|
||||
{ device_id: 'USB-3', device_desc: 'Kingston 16GB', locker_location: 'DT' },
|
||||
{ device_id: 'USB-4', device_desc: 'SanDisk 32GB', locker_location: 'Engineer' },
|
||||
{ device_id: 'USB-10', device_desc: 'SanDisk 32GB', locker_location: 'Engineer' },
|
||||
{ device_id: 'USB-11', device_desc: 'Kingston 64GB', locker_location: 'B11' },
|
||||
]
|
||||
|
||||
describe('filterdrives', () => {
|
||||
it('an empty box is not a filter', () => {
|
||||
expect(filterdrives(DRIVES, '')).toHaveLength(4)
|
||||
expect(filterdrives(DRIVES, ' ')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('finds a drive by its id', () => {
|
||||
expect(filterdrives(DRIVES, 'usb-4').map(d => d.device_id)).toEqual(['USB-4'])
|
||||
})
|
||||
|
||||
it('finds drives by what is written on them', () => {
|
||||
expect(filterdrives(DRIVES, 'kingston').map(d => d.device_id))
|
||||
.toEqual(['USB-3', 'USB-11'])
|
||||
})
|
||||
|
||||
it('finds a locker by name', () => {
|
||||
expect(filterdrives(DRIVES, 'engineer').map(d => d.device_id))
|
||||
.toEqual(['USB-4', 'USB-10'])
|
||||
})
|
||||
|
||||
it('finds a locker the way somebody says it out loud', () => {
|
||||
expect(filterdrives(DRIVES, 'locker engineer').map(d => d.device_id))
|
||||
.toEqual(['USB-4', 'USB-10'])
|
||||
})
|
||||
|
||||
it('takes the words in any order', () => {
|
||||
expect(filterdrives(DRIVES, 'sandisk engineer').map(d => d.device_id))
|
||||
.toEqual(['USB-4', 'USB-10'])
|
||||
expect(filterdrives(DRIVES, 'engineer sandisk').map(d => d.device_id))
|
||||
.toEqual(['USB-4', 'USB-10'])
|
||||
})
|
||||
|
||||
it('ignores case and stray spacing', () => {
|
||||
expect(filterdrives(DRIVES, ' ENGINEER SanDisk ').map(d => d.device_id))
|
||||
.toEqual(['USB-4', 'USB-10'])
|
||||
})
|
||||
|
||||
it('matches nothing rather than everything when nothing matches', () => {
|
||||
expect(filterdrives(DRIVES, 'zzz')).toEqual([])
|
||||
})
|
||||
|
||||
it('does not fall over on a drive missing its description or locker', () => {
|
||||
const sparse = [{ device_id: 'USB-9' }]
|
||||
expect(matchesdrive(sparse[0], 'usb-9')).toBe(true)
|
||||
expect(matchesdrive(sparse[0], 'engineer')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -58,12 +58,27 @@
|
||||
</div>
|
||||
|
||||
<div class="drive-group">
|
||||
<h2>Available</h2>
|
||||
<div class="drive-group-head">
|
||||
<h2>Available</h2>
|
||||
<!-- Typed, not tapped: this panel has a keyboard, and a cabinet of
|
||||
thirty drives is faster to narrow than to read. -->
|
||||
<div v-if="session.available.length" class="drive-filter">
|
||||
<input ref="filterInput" v-model="drivefilter"
|
||||
class="drive-filter-input"
|
||||
autocomplete="off" spellcheck="false"
|
||||
placeholder="Find a drive, a locker..." />
|
||||
<button v-if="drivefilter" class="drive-filter-clear"
|
||||
title="Clear" @click="drivefilter = ''">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!session.available.length" class="kiosk-hint">
|
||||
No drives are free right now.
|
||||
</p>
|
||||
<p v-else-if="!availabledrives.length" class="kiosk-hint">
|
||||
No free drive matches "{{ drivefilter }}".
|
||||
</p>
|
||||
<div v-else class="drive-list">
|
||||
<button v-for="drive in session.available" :key="drive.device_id"
|
||||
<button v-for="drive in availabledrives" :key="drive.device_id"
|
||||
class="drive-row" :disabled="busy"
|
||||
@click="act(drive, 'checkout')">
|
||||
<span class="drive-name">
|
||||
@@ -117,9 +132,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import api, { settingsApi } from '@/api'
|
||||
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||
import { filterdrives } from '../drivefilter'
|
||||
|
||||
const step = ref('badge')
|
||||
const badge = ref('')
|
||||
@@ -155,6 +171,20 @@ function focusEntry (event) {
|
||||
nextTick(() => entryInput.value?.focus())
|
||||
}
|
||||
|
||||
// Narrowing the free drives. The matcher lives in its own module so it can be
|
||||
// tested without mounting a kiosk.
|
||||
const drivefilter = ref('')
|
||||
const filterInput = ref(null)
|
||||
|
||||
// Focused as the picker opens, so somebody can simply start typing. The badge
|
||||
// step owns the keyboard until then.
|
||||
function focusFilter () {
|
||||
nextTick(() => filterInput.value?.focus())
|
||||
}
|
||||
|
||||
const availabledrives = computed(
|
||||
() => filterdrives(session.value.available, drivefilter.value))
|
||||
|
||||
// Drive ids sort as TEXT everywhere else, which puts -10 before -3 and reads as
|
||||
// a jumble on a wall panel. Compare the numeric runs numerically; falls back to
|
||||
// plain text for ids that carry no digits.
|
||||
@@ -181,6 +211,7 @@ async function startSession (value) {
|
||||
badge.value = scanned
|
||||
manualBadge.value = ''
|
||||
step.value = 'pick'
|
||||
focusFilter()
|
||||
} catch (sessionError) {
|
||||
error.value = messageFrom(sessionError, 'Could not look that badge up')
|
||||
} finally {
|
||||
@@ -247,6 +278,9 @@ function reset () {
|
||||
error.value = ''
|
||||
doneMessage.value = ''
|
||||
manualBadge.value = ''
|
||||
// The next person at the cabinet must not inherit somebody else's filter and
|
||||
// conclude there are no drives left.
|
||||
drivefilter.value = ''
|
||||
focusEntry()
|
||||
}
|
||||
</script>
|
||||
@@ -273,7 +307,7 @@ function reset () {
|
||||
}
|
||||
.kiosk-header {
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
max-width: 72rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -297,8 +331,11 @@ function reset () {
|
||||
max-width: 40rem;
|
||||
width: 100%;
|
||||
}
|
||||
/* The picker holds lists, so its rows stretch - but it keeps the same width as
|
||||
every other step so the panel does not jump when the step changes.
|
||||
/* The picker holds lists, so its rows stretch, and it is WIDER than the steps
|
||||
that hold a single control - a cabinet of thirty drives is three or four
|
||||
columns of one glance rather than a scroll. The steps that are just a keypad
|
||||
or a message stay narrow, because stretching one input across a 24 inch panel
|
||||
reads as a broken page.
|
||||
It is also height-bounded: the lists absorb whatever space is left over and
|
||||
scroll inside themselves, so the PAGE never scrolls and the heading saying
|
||||
whose drives these are stays on screen. A fixed max-height cannot do this -
|
||||
@@ -306,6 +343,7 @@ function reset () {
|
||||
present, which is exactly what it did. */
|
||||
.kiosk-wide {
|
||||
align-items: stretch;
|
||||
max-width: 72rem;
|
||||
max-height: calc(100vh - 9rem);
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -321,7 +359,7 @@ function reset () {
|
||||
.kiosk-wide .drive-group-yours .drive-list { max-height: 30vh; }
|
||||
.kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; }
|
||||
.kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; }
|
||||
.kiosk-hint { color: var(--textmuted); text-align: center; margin: 0; }
|
||||
.kiosk-hint { color: var(--text-light); text-align: center; margin: 0; }
|
||||
.kiosk-error {
|
||||
padding: 0.9rem 1.1rem;
|
||||
border: 1px solid var(--danger, #b3261e);
|
||||
@@ -335,12 +373,47 @@ function reset () {
|
||||
|
||||
.drive-group { width: 100%; }
|
||||
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
|
||||
/* Two columns once there is room, so a cabinet of a dozen drives is one glance
|
||||
rather than a scroll. The list itself scrolls when it has to - the PAGE must
|
||||
not, or the person loses the heading telling them whose drives these are. */
|
||||
/* Heading and filter on one line: the filter belongs to the list under it, and
|
||||
a row of its own would cost height the lists need. */
|
||||
.drive-group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.drive-filter { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.drive-filter-input {
|
||||
/* Touch-sized like everything else here, and wide enough for a locker name
|
||||
rather than just a drive number. */
|
||||
min-width: 16rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 1.1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card-solid);
|
||||
color: var(--text);
|
||||
}
|
||||
.drive-filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.drive-filter-clear {
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* As many columns as the panel has room for - four on a wall display, one on a
|
||||
small screen - so a cabinet of a dozen drives is one glance rather than a
|
||||
scroll. The list itself scrolls when it has to; the PAGE must not, or the
|
||||
person loses the heading telling them whose drives these are. */
|
||||
.drive-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -364,7 +437,7 @@ function reset () {
|
||||
}
|
||||
.drive-row:disabled { opacity: 0.5; }
|
||||
.drive-name { display: flex; flex-direction: column; gap: 0.15rem; }
|
||||
.drive-name small { color: var(--textmuted); font-size: 0.85rem; }
|
||||
.drive-name small { color: var(--text-light); font-size: 0.85rem; }
|
||||
.drive-action {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.5rem 1.1rem;
|
||||
|
||||
Reference in New Issue
Block a user