Select All Pattern
This guide documents the "Select All" pattern used in Shelf.nu for bulk operations across multiple pages of filtered data.
Overview
The ALL_SELECTED_KEY pattern enables users to select all items matching current filters, even when those items span multiple pages. This is critical for bulk operations like:
- Exporting filtered assets
- Bulk deleting assets
- Generating QR codes for multiple assets
- Exporting bookings
- Exporting team members (NRMs)
The Problem
When a user has:
- Active filters applied (e.g., category, location, tags, search)
- Multiple pages of results
- Clicks "Select All"
We need to:
- Include all matching items, not just the current page
- Respect all active filters from the index page
- Pass filter context from the UI to the backend
The Solution: ALL_SELECTED_KEY
Located in app/utils/list.ts:
export const ALL_SELECTED_KEY = "all-selected";
export function isSelectingAllItems(selectedItems: ListItemData[]) {
return !!selectedItems.find((item) => item.id === ALL_SELECTED_KEY);
}When the user clicks "Select All", ALL_SELECTED_KEY is added to the selected items array. This signals to backend logic that we need to fetch all items matching the current filters.
Implementation Pattern
Three-Layer Architecture
Every bulk operation using ALL_SELECTED_KEY follows this pattern:
1. Component Layer (Button/Form)
↓ Passes: assetIds + currentSearchParams
2. Route/API Layer (Loader/Action)
↓ Fetches settings + Extracts and forwards parameters
3. Service Layer (Business Logic)
↓ Uses resolveAssetIdsForBulkOperation helper (handles both simple & advanced mode)Important: Simple vs Advanced Mode
Shelf has two index modes that require different filtering approaches:
- Simple Mode: Uses Prisma where clauses (
Prisma.AssetWhereInput) - Advanced Mode: Uses raw SQL queries with filter parsing
The resolveAssetIdsForBulkOperation helper (see below) handles both modes automatically, ensuring consistent behavior across all bulk operations.
1. Component Layer: Pass Search Params
Key Requirements:
- Use
useSearchParams()hook to capture current URL state - Pass
currentSearchParamsalongside selected IDs - Only pass search params when
ALL_SELECTED_KEYis present
Example: app/components/assets/assets-index/export-assets-button.tsx
import { useSearchParams } from "~/hooks/search-params";
import { isSelectingAllItems, ALL_SELECTED_KEY } from "~/utils/list";
export function ExportAssetsButton() {
const selectedAssets = useAtomValue(selectedBulkItemsAtom);
const [searchParams] = useSearchParams();
const allSelected = isSelectingAllItems(selectedAssets);
// Get the assetIds from the atom
const assetIds = selectedAssets.map((asset) => asset.id);
// Build search params including current filters
const exportSearchParams = new URLSearchParams();
if (assetIds.length > 0) {
exportSearchParams.set("assetIds", assetIds.join(","));
}
// If all are selected, pass current search params to apply filters
if (allSelected) {
exportSearchParams.set(
"assetIndexCurrentSearchParams",
searchParams.toString()
);
}
const handleExport = async () => {
const response = await fetch(
`/assets/export/filename.csv?${exportSearchParams.toString()}`
);
// ... handle download
};
}2. Route/API Layer: Extract and Forward
Key Requirements:
- Extract both
assetIdsandcurrentSearchParamsfrom request - Fetch asset index settings using
getAssetIndexSettings - Use
canUseBarcodesfromrequirePermission(don't accesscurrentOrganization.barcodesEnabled) - Pass settings to service layer functions
- Use a descriptive parameter name (e.g.,
currentSearchParams)
Example: app/routes/api+/assets.bulk-mark-availability.ts
import { getAssetIndexSettings } from "~/modules/asset-index-settings/service.server";
export async function action({ context, request }: ActionFunctionArgs) {
const { userId } = context.getSession();
// Get canUseBarcodes directly from requirePermission
const { organizationId, canUseBarcodes } = await requirePermission({
request,
userId,
entity: PermissionEntity.asset,
action: PermissionAction.update,
});
// Fetch asset index settings to determine mode (SIMPLE or ADVANCED)
const settings = await getAssetIndexSettings({
userId,
organizationId,
canUseBarcodes, // Use from requirePermission, not currentOrganization.barcodesEnabled
});
const { assetIds, type, currentSearchParams } = parseData(
await request.formData(),
BulkMarkAvailabilitySchema.and(CurrentSearchParamsSchema)
);
await bulkMarkAvailability({
organizationId,
assetIds,
type,
currentSearchParams, // Pass filter context
settings, // Pass settings for mode detection
});
return payload({ success: true });
}Important Notes:
- ✅ Always use
canUseBarcodesfromrequirePermission - ❌ Never use
currentOrganization.barcodesEnabled - ✅ Fetch settings in every route that does bulk operations
- ✅ Pass
settingsto service functions for mode detection
3. Service Layer: Use the Helper Function
Key Requirements:
- Use
resolveAssetIdsForBulkOperationhelper to resolve IDs - Import the helper at the top of the file (no dynamic imports)
- Pass
assetIds,organizationId,currentSearchParams, andsettings - The helper automatically handles both Simple and Advanced modes
- Use resolved IDs in your bulk operations
Example: app/modules/asset/service.server.ts
// ✅ Import at top of file
import { resolveAssetIdsForBulkOperation } from "./bulk-operations-helper.server";
export async function bulkMarkAvailability({
organizationId,
assetIds,
type,
currentSearchParams,
settings,
}: {
organizationId: Asset["organizationId"];
assetIds: Asset["id"][];
type: "available" | "unavailable";
currentSearchParams?: string | null;
settings: AssetIndexSettings;
}) {
try {
// Step 1: Resolve IDs using the helper (handles both modes)
const resolvedIds = await resolveAssetIdsForBulkOperation({
assetIds,
organizationId,
currentSearchParams,
settings,
});
// Step 2: Use resolved IDs in your bulk operation
await db.asset.updateMany({
where: {
id: { in: resolvedIds },
organizationId,
availableToBook: type === "unavailable",
},
data: { availableToBook: type === "available" },
});
return true;
} catch (cause) {
throw new ShelfError({
cause,
message: "Failed to update asset availability",
additionalData: { assetIds, organizationId, type },
label: "Assets",
});
}
}Helper Functions
resolveAssetIdsForBulkOperation
Location: app/modules/asset/bulk-operations-helper.server.ts
This is the main helper for all bulk operations. It automatically handles both Simple and Advanced modes.
export async function resolveAssetIdsForBulkOperation({
assetIds,
organizationId,
currentSearchParams,
settings,
}: {
assetIds: Asset["id"][];
organizationId: Asset["organizationId"];
currentSearchParams?: string | null;
settings: AssetIndexSettings;
}): Promise<string[]> {
// Case 1: Specific selection - return IDs as-is
if (!assetIds.includes(ALL_SELECTED_KEY)) {
return assetIds;
}
// Case 2: Select all - resolve based on mode
const isAdvancedMode = settings.mode === "ADVANCED";
if (isAdvancedMode && currentSearchParams) {
// Advanced mode: Use raw SQL with filter parsing
return getAdvancedFilteredAssetIds({
organizationId,
currentSearchParams,
settings,
});
} else {
// Simple mode: Use Prisma where clause
const where = getAssetsWhereInput({
organizationId,
currentSearchParams,
});
const assets = await db.asset.findMany({
where,
select: { id: true },
});
return assets.map((a) => a.id);
}
}How it works:
- If
ALL_SELECTED_KEYis not present → returns the provided IDs directly - If
ALL_SELECTED_KEYis present → resolves all matching IDs:- Advanced mode: Uses
getAdvancedFilteredAssetIdswith raw SQL - Simple mode: Uses
getAssetsWhereInputwith Prisma
- Advanced mode: Uses
Benefits:
- ✅ Single source of truth for ID resolution
- ✅ Handles both modes automatically
- ✅ Compatible with
updateMany,deleteMany, etc. - ✅ Consistent behavior across all bulk operations
getAssetsWhereInput
Location: app/modules/asset/utils.server.ts
This function builds a Prisma where clause from URL search parameters (used in Simple mode):
export function getAssetsWhereInput({
organizationId,
currentSearchParams,
}: {
organizationId: Asset["organizationId"];
currentSearchParams?: string | null;
}) {
const where: Prisma.AssetWhereInput = { organizationId };
if (!currentSearchParams) {
return where;
}
const searchParams = new URLSearchParams(currentSearchParams);
const paramsValues = getParamsValues(searchParams);
const { categoriesIds, locationIds, tagsIds, search, teamMemberIds } =
paramsValues;
// Apply filters to where clause
if (search) {
where.title = {
contains: search.toLowerCase().trim(),
mode: "insensitive",
};
}
if (categoriesIds && categoriesIds.length > 0) {
where.categoryId = { in: categoriesIds };
}
// ... more filter logic
return where;
}Supports filters for:
- Text search
- Categories (including "uncategorized")
- Tags (including "untagged")
- Locations (including "without-location")
- Team members / custodians
- Status
Existing Implementations
All asset bulk operations now use the resolveAssetIdsForBulkOperation helper pattern:
Asset Bulk Operations (Using Helper)
All these operations follow the same pattern shown above:
bulkMarkAvailability - Mark assets as available/unavailable
- Route:
app/routes/api+/assets.bulk-mark-availability.ts - Service:
app/modules/asset/service.server.ts
- Route:
bulkUpdateAssetLocation - Update location for multiple assets
- Route:
app/routes/api+/assets.bulk-update-location.ts - Service:
app/modules/asset/service.server.ts
- Route:
bulkUpdateAssetCategory - Update category for multiple assets
- Route:
app/routes/api+/assets.bulk-update-category.ts - Service:
app/modules/asset/service.server.ts
- Route:
bulkAssignAssetTags - Assign/remove tags for multiple assets
- Route:
app/routes/api+/assets.bulk-assign-tags.ts - Service:
app/modules/asset/service.server.ts
- Route:
bulkCheckOutAssets (bulkAssignCustody) - Assign custody
- Route:
app/routes/api+/assets.bulk-assign-custody.ts - Service:
app/modules/asset/service.server.ts
- Route:
bulkCheckInAssets (bulkReleaseCustody) - Release custody
- Route:
app/routes/api+/assets.bulk-release-custody.ts - Service:
app/modules/asset/service.server.ts
- Route:
bulkRemoveAssetsFromKits - Remove assets from kits
- Route:
app/routes/api+/assets.bulk-remove-from-kits.ts - Service:
app/modules/kit/service.server.ts
- Route:
bulkDeleteAssets - Delete multiple assets
- Route:
app/routes/_layout+/assets._index.tsx(action) - Route:
app/routes/_layout+/admin-dashboard+/org.$organizationId.assets.tsx(action) - Service:
app/modules/asset/service.server.ts
- Route:
Other Select All Implementations
These use different patterns appropriate to their use case:
Export Assets - Uses advanced filtering directly
- Button:
app/components/assets/assets-index/export-assets-button.tsx - Route:
app/routes/_layout+/assets.export.$fileName[.csv].tsx - Service:
app/utils/csv.server.ts→exportAssetsFromIndexToCsv
- Button:
Bulk QR Code Download - Uses
getAssetsWhereInputdirectly- API:
app/routes/api+/assets.get-assets-for-bulk-qr-download.ts
- API:
Export Bookings
- Button:
app/components/booking/export-bookings-button.tsx - Service:
app/utils/csv.server.ts→exportBookingsFromIndexToCsv
- Button:
Export Team Members (NRMs)
- Button:
app/components/nrm/export-nrm-button.tsx - Service:
app/utils/csv.server.ts→exportNRMsToCsv
- Button:
Common Pitfalls
❌ Pitfall 1: Not Passing Search Params
Symptom: When "Select All" is used, all organization items are exported/affected, ignoring filters.
Cause: Only passing assetIds without currentSearchParams.
Fix: Always pass search params when ALL_SELECTED_KEY is present:
// ❌ Wrong
const url = `/export?assetIds=${assetIds.join(",")}`;
// ✅ Correct
if (allSelected) {
exportSearchParams.set("currentSearchParams", searchParams.toString());
}❌ Pitfall 2: Using Stale Cookie Filters
Symptom: Export uses old filters from previous page visits.
Cause: Relying on cookies instead of explicit URL params.
Fix: Pass explicit search params for "Select All" operations:
// ✅ Use explicit params when selecting all
const filtersToUse =
takeAll && currentSearchParams
? currentSearchParams // Use current page state
: cachedFilters; // Fall back to cookies for normal operations❌ Pitfall 3: Forgetting takeAll Flag
Symptom: Query limited to current page even with filters applied.
Cause: Not setting takeAll flag or not removing pagination.
Fix: Pass takeAll to service functions:
const { assets } = await getAdvancedPaginatedAndFilterableAssets({
// ... other params
takeAll, // Removes LIMIT/OFFSET from query
assetIds: takeAll ? undefined : ids, // Only use specific IDs when not taking all
});Performance Considerations
Why Pass Search Params Explicitly?
- Correctness - Guarantees filter state matches what user sees
- Performance - Skips async cookie parsing when params are available
- Reliability - Works with SPA navigation, bookmarked URLs, browser history
Query Optimization
When takeAll is true:
- Remove
LIMITandOFFSETfrom queries - Still apply
WHEREfilters to narrow results - Consider adding progress indicators for large datasets
const paginationClause = takeAll
? Prisma.empty
: Prisma.sql`LIMIT ${take} OFFSET ${skip}`;Testing Checklist
When implementing a new bulk operation with Select All:
- [ ] Filters are applied correctly when selecting all
- [ ] Works with multi-page datasets
- [ ] Respects search text filter
- [ ] Respects dropdown filters (category, location, tags, etc.)
- [ ] Works with combined filters
- [ ] Handles "uncategorized", "untagged", "without-location" special cases
- [ ] Performance is acceptable with large datasets
- [ ] Error handling for failed operations
Quick Reference
Minimal Implementation Checklist
1. Component/Button:
const [searchParams] = useSearchParams();
const allSelected = isSelectingAllItems(selectedItems);
if (allSelected) {
params.set("currentSearchParams", searchParams.toString());
}2. Route/API:
import { getAssetIndexSettings } from "~/modules/asset-index-settings/service.server";
// Get canUseBarcodes from requirePermission
const { organizationId, canUseBarcodes } = await requirePermission({...});
// Fetch settings
const settings = await getAssetIndexSettings({
userId,
organizationId,
canUseBarcodes,
});
// Extract params
const { assetIds, currentSearchParams } = parseData(formData, schema);
// Pass to service
await bulkOperation({
assetIds,
organizationId,
currentSearchParams,
settings,
});3. Service:
import { resolveAssetIdsForBulkOperation } from "./bulk-operations-helper.server";
// Resolve IDs (handles both Simple and Advanced mode)
const resolvedIds = await resolveAssetIdsForBulkOperation({
assetIds,
organizationId,
currentSearchParams,
settings,
});
// Use resolved IDs in your operation
await db.asset.updateMany({
where: { id: { in: resolvedIds }, organizationId },
data: {
/* your updates */
},
});Related Documentation
Questions or Issues?
If you encounter issues with the Select All pattern:
- Check existing implementations for reference
- Verify all three layers are properly connected
- Ensure search params are being passed through the chain
- Test with actual filters applied across multiple pages
