Scanner Drawer Development Guide
This guide explains how to create and maintain scanner drawers in Shelf.nu, ensuring consistency and proper use of existing patterns and atoms.
Overview
Scanner drawers are specialized UI components that manage scanned QR code items (assets and kits) for various operations like:
- Adding assets to bookings
- Adding assets to kits
- Adding assets to locations
- Assigning/releasing custody
- Checking in/out assets
Key Components & Patterns
QR Scanner Atoms
All scanner drawers should use the centralized atoms in app/atoms/qr-scanner.ts:
Core Atoms
scannedItemsAtom: Main state containing all scanned items keyed by QR IDscannedItemIdsAtom: ⭐ Derived atom that efficiently extracts asset and kit IDs from scanned itemsaddScannedItemAtom: Adds a new scanned item to the stateupdateScannedItemAtom: Updates an existing scanned item with dataclearScannedItemsAtom: Clears all scanned itemsremoveScannedItemAtom: Removes a single item by QR IDremoveMultipleScannedItemsAtom: Removes multiple items by QR IDsremoveScannedItemsByAssetIdAtom: Removes items by asset/kit IDs
Data Structure
type ScanListItems = {
[qrId: string]: ScanListItem;
};
type ScanListItem =
| {
data?: KitFromQr | AssetFromQr;
error?: string;
type?: "asset" | "kit";
codeType?: "qr" | "barcode";
}
| undefined;The scannedItemIdsAtom returns:
{
assetIds: string[]; // Array of asset IDs
kitIds: string[]; // Array of kit IDs
idsTotalCount: number; // Total count of both assets and kits
}When to Use Each Atom
scannedItemIdsAtom: For form submissions, counts, and any logic needing just the IDsscannedItemsAtom: When you need full objects for complex business logic or rendering- Remove atoms: For handling blockers and invalid items in scanner drawers
⚠️ Always Use scannedItemIdsAtom for ID Extraction
❌ DON'T manually filter items to extract IDs:
// BAD - Manual filtering (inefficient and inconsistent)
const assetIds = Object.values(items)
.filter((item) => !!item && item.data && item.type === "asset")
.map((item) => item?.data?.id);
const kitIds = Object.values(items)
.filter((item) => !!item && item.data && item.type === "kit")
.map((item) => item?.data?.id);✅ DO use the purpose-built atom:
// GOOD - Use the efficient derived atom
const { assetIds, kitIds, idsTotalCount } = useAtomValue(scannedItemIdsAtom);Required Imports
import { useAtomValue, useSetAtom } from "jotai";
import {
clearScannedItemsAtom,
removeScannedItemAtom,
scannedItemsAtom,
scannedItemIdsAtom, // Always import this for ID extraction
removeScannedItemsByAssetIdAtom,
removeMultipleScannedItemsAtom,
} from "~/atoms/qr-scanner";Drawer Architecture
Base Structure
All scanner drawers follow this structure:
- Import required atoms and utilities
- Get scanned data using atoms
- Filter and prepare data for rendering
- Set up blockers for invalid items
- Render using ConfigurableDrawer
Standard Drawer Template
import { useAtomValue, useSetAtom } from "jotai";
import { z } from "zod";
import {
clearScannedItemsAtom,
removeScannedItemAtom,
scannedItemsAtom,
scannedItemIdsAtom,
removeScannedItemsByAssetIdAtom,
removeMultipleScannedItemsAtom,
} from "~/atoms/qr-scanner";
import { createBlockers } from "../blockers-factory";
import ConfigurableDrawer from "../configurable-drawer";
// Define your schema
export const yourDrawerSchema = z.object({
assetIds: z.array(z.string()).min(1),
});
export default function YourDrawer({
className,
style,
isLoading,
defaultExpanded = false,
}: {
className?: string;
style?: CSSProperties;
isLoading?: boolean;
defaultExpanded?: boolean;
}) {
// 1. Get scanned data using atoms
const items = useAtomValue(scannedItemsAtom);
const { assetIds, kitIds, idsTotalCount } = useAtomValue(scannedItemIdsAtom);
// 2. Get atom setters for clearing/removing items
const clearList = useSetAtom(clearScannedItemsAtom);
const removeItem = useSetAtom(removeScannedItemAtom);
const removeAssetsFromList = useSetAtom(removeScannedItemsByAssetIdAtom);
const removeItemsFromList = useSetAtom(removeMultipleScannedItemsAtom);
// 3. Filter and prepare data for rendering (when you need full objects)
const assets = Object.values(items)
.filter((item) => !!item && item.data && item.type === "asset")
.map((item) => item?.data as AssetFromQr);
const kits = Object.values(items)
.filter((item) => !!item && item.data && item.type === "kit")
.map((item) => item?.data as KitFromQr);
// 4. Set up error filtering
const errors = Object.entries(items).filter(([, item]) => !!item?.error);
// 5. Create blockers for invalid items
const blockerConfigs = [
{
condition: errors.length > 0,
count: errors.length,
message: (count: number) => (
<>
<strong>{`${count} QR codes `}</strong> are invalid.
</>
),
onResolve: () => removeItemsFromList(errors.map(([qrId]) => qrId)),
},
// Add more blocker configurations as needed
];
const [hasBlockers, Blockers] = createBlockers({
blockerConfigs,
onResolveAll: () => {
// Handle resolving all blockers
},
});
// 6. Render using ConfigurableDrawer
return (
<ConfigurableDrawer
schema={yourDrawerSchema}
defaultValues={{ assetIds }}
title="Your Drawer Title"
Blockers={Blockers}
hasBlockers={hasBlockers}
clearList={clearList}
// ... other props
/>
);
}Common Patterns
Asset/Kit Filtering for Different Purposes
// For form submission - use the atom
const { assetIds, kitIds } = useAtomValue(scannedItemIdsAtom);
// For complex business logic - filter full objects when needed
const availableAssets = assets.filter(
(asset) => asset.status === AssetStatus.AVAILABLE
);
// For kit asset expansion
const allAssetIds = Array.from(
new Set([...assetIds, ...kits.flatMap((k) => k.assets.map((a) => a.id))])
);Blocker Patterns
// Standard error blocker
{
condition: errors.length > 0,
count: errors.length,
message: (count: number) => (
<>
<strong>{`${count} QR codes `}</strong> are invalid.
</>
),
onResolve: () => removeItemsFromList(errors.map(([qrId]) => qrId)),
}
// Kit blocker (when kits not allowed)
{
condition: kitQrIds.length > 0,
count: kitQrIds.length,
message: (count: number) => (
<>
<strong>{`${count} kit${count > 1 ? "s" : ""}`}</strong> detected.
Kits cannot be added to this context.
</>
),
onResolve: () => removeItemsFromList(kitQrIds),
}
// Status-based blockers
{
condition: assetsInWrongStatus.length > 0,
count: assetsInWrongStatus.length,
message: (count: number) => (
<>
<strong>{`${count} asset${count > 1 ? "s are" : " is"}`}</strong> in the wrong status.
</>
),
onResolve: () => removeAssetsFromList(assetsInWrongStatusIds),
}Factory Components
ConfigurableDrawer
The main drawer component that handles:
- Form submission with Zod validation
- Standard drawer UI and interactions
- Integration with blockers
- Error handling
createBlockers
Factory function for creating consistent blocker UI:
const [hasBlockers, Blockers] = createBlockers({
blockerConfigs: [...],
onResolveAll: () => { /* cleanup logic */ },
});createAvailabilityLabels
Factory for creating status labels:
const labels = createAvailabilityLabels(assetLabelPresets.booking);Testing Your Drawer
Manual Testing Checklist
Basic Functionality:
- [ ] Drawer opens and closes correctly
- [ ] Items appear when scanned
- [ ] Form submission works with valid items
Blocker Testing:
- [ ] Invalid QR codes show error blockers
- [ ] Wrong item types (kits when not allowed) show blockers
- [ ] Wrong status items show appropriate blockers
- [ ] "Resolve All" button works correctly
Edge Cases:
- [ ] Empty state displays correctly
- [ ] Duplicate items are handled properly
- [ ] Form validation works with no items
- [ ] Large numbers of items perform well
Atom Consistency:
- [ ] Uses
scannedItemIdsAtomfor ID extraction - [ ] Properly imports all required atoms
- [ ] Consistent with other drawers
- [ ] Uses
Performance Considerations
✅ Efficient Patterns
- Use
scannedItemIdsAtomfor ID extraction (computed once, used everywhere) - Only filter full objects when you need the complete data
- Use blockers to prevent unnecessary form submissions
❌ Performance Anti-patterns
- Manual filtering of
Object.values(items)for IDs - Re-computing the same filtered arrays multiple times
- Not using the derived atoms
Common Pitfalls
- Not using scannedItemIdsAtom: Always use the atom for ID extraction
- Inconsistent imports: Follow the standard import pattern
- Missing blockers: Always handle invalid items with appropriate blockers
- Complex filtering in render: Move complex logic outside render where possible
- Not handling kits properly: Remember kits contain assets that may need expansion
Examples
See these existing drawers for reference:
assign-custody-drawer.tsx- Full featured with asset and kit handlingupdate-location-drawer.tsx- Simple asset-only draweradd-assets-to-booking-drawer.tsx- Complex business logic with kit expansionpartial-checkin-drawer.tsx- Advanced filtering and status management
Migration Guide
If you have an existing drawer that doesn't use scannedItemIdsAtom:
Add
scannedItemIdsAtomto importsReplace manual ID filtering with atom usage:
typescript// Replace this: const assetIds = Object.values(items) .filter((item) => !!item && item.data && item.type === "asset") .map((item) => item?.data?.id); // With this: const { assetIds } = useAtomValue(scannedItemIdsAtom);Keep full object filtering only where you need the complete data
Test thoroughly to ensure behavior is unchanged
