This document outlines the comprehensive implementation plan for adding a global search feature to the Vendure dashboard under packages/dashboard, similar to modern command palette interfaces.
The search feature will be built as:
cmdk components from our shadcn componentsAll Vendure built-in entities should be searchable:
Catalog Entities:
Customer Management:
Order Management:
Configuration:
System & Access:
Marketing:
Dynamic discovery and indexing of custom entities:
For each built-in database entity there must be a data mapper in place. For custom entities there is the need to provide these entity mappers. Developers must also be able to extend the data mapping for built-in entities, which means that the built-in data mapper gets extended by a provided data mappers. The task of a data mapper is to map the entity to the search index item.
There are settings and systems pages that are not based on list-views, like the job queue or scheduled tasks. These pages are searchable through a quick action that is registered.
A comprehensive quick actions system that includes both global and context-aware actions:
Always available actions regardless of current page:
Actions that appear only on specific pages/contexts:
defineDashboardExtensionsDevelopers can register custom actions:
The search system uses a dedicated index that aggregates data from all internal and external sources:
interface SearchIndexEntry {
id: string;
type: SearchResultType;
title: string;
subtitle?: string;
description?: string;
content: string; // Full searchable text content
url: string;
thumbnailUrl?: string;
metadata: Record<string, any>;
permissions?: string[]; // Required permissions to view
channelIds: string[]; // Channel access
entityId?: string; // Original entity ID (for internal entities)
entityName?: string; // Original entity name
lastUpdated: Date;
searchVector?: string; // For advanced search backends
}
// Default implementation using the selected database (PostgreSQL, MySQL, etc.)
@Injectable()
export class DatabaseSearchStrategy implements SearchStrategy {
async indexContent(entries: SearchIndexEntry[]): Promise<void> {
// Upsert entries into search_index table
// Use database full-text search capabilities
}
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
// Use database full-text search with ranking
// PostgreSQL: ts_rank, ts_headline
// MySQL: MATCH() AGAINST()
}
}
// Alternative implementation for TypeSense
@Injectable()
export class TypeSenseSearchStrategy implements SearchStrategy {
async indexContent(entries: SearchIndexEntry[]): Promise<void> {
// Index into TypeSense collection
// Leverage TypeSense's typo tolerance and faceting
}
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
// Use TypeSense search with advanced features
// Typo tolerance, faceting, geo-search, etc.
}
}
This only extend the Admin API of Vendure.
// packages/dashboard/plugin/api/search-extensions.ts
type GlobalSearchResult {
id: String!
type: SearchResultType!
title: String!
subtitle: String
description: String
url: String!
thumbnailUrl: String
metadata: JSON
relevanceScore: Float!
lastModified: DateTime
}
enum SearchResultType {
# Core Entities
PRODUCT
PRODUCT_VARIANT
CUSTOMER
ORDER
COLLECTION
ADMINISTRATOR
CHANNEL
ASSET
FACET
FACET_VALUE
PROMOTION
PAYMENT_METHOD
SHIPPING_METHOD
TAX_CATEGORY
TAX_RATE
COUNTRY
ZONE
ROLE
CUSTOMER_GROUP
STOCK_LOCATION
TAG
# Custom/Plugin Entities
CUSTOM_ENTITY
# Dashboard Content
NAVIGATION
SETTINGS
QUICK_ACTION
# External Content
DOCUMENTATION
BLOG_POST
PLUGIN
WEBSITE_CONTENT
}
input GlobalSearchInput {
query: String!
types: [SearchResultType!]
limit: Int = 20
skip: Int = 0
channelId: String
}
extend type Query {
globalSearch(input: GlobalSearchInput!): [GlobalSearchResult!]!
}
// packages/dashboard/plugin/service/global-search.service.ts
@Injectable()
export class GlobalSearchService {
constructor(private searchStrategy: SearchStrategy) {}
async search(input: GlobalSearchInput): Promise<GlobalSearchResult[]> {
// Use the configured search strategy to search the index
const results = await this.searchStrategy.search(input.query, {
types: input.types,
limit: input.limit,
offset: input.skip,
channelId: input.channelId,
});
return results;
}
}
// packages/dashboard/plugin/service/search-strategy.interface.ts
export interface SearchStrategy {
// Index content into the search backend
indexContent(entries: SearchIndexEntry[]): Promise<void>;
// Remove content from index
removeFromIndex(ids: string[]): Promise<void>;
// Search the index
search(query: string, options: SearchOptions): Promise<SearchResult[]>;
// Health check for the search backend
healthCheck(): Promise<boolean>;
// Get search statistics
getStats(): Promise<SearchStats>;
}
interface SearchOptions {
types?: SearchResultType[];
limit?: number;
offset?: number;
channelId?: string;
permissions?: string[];
}
interface SearchResult {
id: string;
type: SearchResultType;
title: string;
subtitle?: string;
description?: string;
url: string;
thumbnailUrl?: string;
metadata: Record<string, any>;
relevanceScore: number;
highlights?: SearchHighlight[];
}
interface SearchHighlight {
field: string;
snippet: string;
}
interface SearchStats {
totalEntries: number;
lastIndexed: Date;
backendType: string;
indexSizeBytes?: number;
}
// packages/dashboard/plugin/service/search-indexing.service.ts
@Injectable()
export class SearchIndexingService {
constructor(
private searchStrategy: SearchStrategy,
private entityDiscoveryService: EntityDiscoveryService,
private documentationContentService: DocumentationContentService,
private websiteContentService: WebsiteContentService,
) {}
// Full reindex of all content
async fullReindex(): Promise<void> {
Logger.info('Starting full search index rebuild');
const entries: SearchIndexEntry[] = [];
// Index internal entities
entries.push(...(await this.indexInternalEntities()));
// Index custom entities
entries.push(...(await this.indexCustomEntities()));
// Index external content
entries.push(...(await this.indexExternalContent()));
// Update the search index
await this.searchStrategy.indexContent(entries);
Logger.info(`Search index rebuilt with ${entries.length} entries`);
}
// Incremental update for specific entities
async updateEntity(entityType: string, entityId: string): Promise<void> {
const entry = await this.createIndexEntryForEntity(entityType, entityId);
if (entry) {
await this.searchStrategy.indexContent([entry]);
}
}
// Remove entity from index
async removeEntity(entityType: string, entityId: string): Promise<void> {
const indexId = `${entityType}:${entityId}`;
await this.searchStrategy.removeFromIndex([indexId]);
}
private async indexInternalEntities(): Promise<SearchIndexEntry[]> {
const entries: SearchIndexEntry[] = [];
// Index all core Vendure entities
const entityTypes = [
'Product',
'ProductVariant',
'Customer',
'Order',
'Collection',
'Asset',
'Facet',
'FacetValue',
'Administrator',
'Role',
'Channel',
'Country',
'Zone',
'TaxCategory',
'TaxRate',
'PaymentMethod',
'ShippingMethod',
'Promotion',
'StockLocation',
];
for (const entityType of entityTypes) {
entries.push(...(await this.indexEntityType(entityType)));
}
return entries;
}
private async indexEntityType(entityType: string): Promise<SearchIndexEntry[]> {
// Implementation specific to each entity type
// Extract searchable text, permissions, channels, etc.
}
private async indexExternalContent(): Promise<SearchIndexEntry[]> {
const entries: SearchIndexEntry[] = [];
try {
// Index documentation content
const docEntries = await this.documentationContentService.fetchDocumentationContent();
entries.push(...docEntries);
// Index blog posts from Storyblok
const blogEntries = await this.websiteContentService.fetchBlogPosts();
entries.push(...blogEntries);
// Index plugins
const pluginEntries = await this.websiteContentService.fetchPlugins();
entries.push(...pluginEntries);
Logger.info(`Indexed ${entries.length} external content items`);
} catch (error) {
Logger.error('Failed to index external content', error);
}
return entries;
}
// Method to refresh only external content (can be called separately)
async refreshExternalContent(): Promise<void> {
Logger.info('Refreshing external content index');
// Remove existing external content from index
await this.searchStrategy.removeFromIndex(['docs:*', 'blog:*', 'plugin:*']);
// Re-index external content
const externalEntries = await this.indexExternalContent();
await this.searchStrategy.indexContent(externalEntries);
Logger.info('External content refresh completed');
}
}
Using Vendure's built-in scheduling features:
// packages/dashboard/plugin/tasks/search-index-task.ts
import { ScheduledTask, TaskContext } from '@vendure/core';
export const searchFullReindexTask = new ScheduledTask({
name: 'search-full-reindex',
cron: cron => cron.daily().atHour(2), // Daily at 2 AM
run: async (ctx: TaskContext) => {
const searchIndexingService = ctx.injector.get(SearchIndexingService);
try {
await searchIndexingService.fullReindex();
ctx.logger.info('Search full reindex completed successfully');
} catch (error) {
ctx.logger.error('Search full reindex failed', error);
throw error;
}
},
});
export const searchIncrementalUpdateTask = new ScheduledTask({
name: 'search-incremental-update',
cron: cron => cron.everyMinutes(15), // Every 15 minutes
run: async (ctx: TaskContext) => {
const searchIndexingService = ctx.injector.get(SearchIndexingService);
try {
await searchIndexingService.incrementalUpdate();
ctx.logger.info('Search incremental update completed');
} catch (error) {
ctx.logger.error('Search incremental update failed', error);
throw error;
}
},
});
export const searchExternalContentRefreshTask = new ScheduledTask({
name: 'search-external-content-refresh',
cron: cron => cron.everyHours(12), // Every 12 hours
run: async (ctx: TaskContext) => {
const searchIndexingService = ctx.injector.get(SearchIndexingService);
try {
await searchIndexingService.refreshExternalContent();
ctx.logger.info('External content refresh completed');
} catch (error) {
ctx.logger.error('External content refresh failed', error);
throw error;
}
},
});
For intensive indexing operations that should run in the background:
// packages/dashboard/plugin/service/search-job.service.ts
import { Job, JobQueue, JobQueueService } from '@vendure/core';
interface ReindexJobData {
entityTypes?: string[];
forceFullReindex?: boolean;
}
@Injectable()
export class SearchJobService {
private jobQueue: JobQueue<ReindexJobData>;
constructor(
private jobQueueService: JobQueueService,
private indexingService: SearchIndexingService,
) {
this.jobQueue = this.jobQueueService.createQueue({
name: 'search-indexing',
process: this.processSearchJob.bind(this),
});
}
async queueFullReindex(): Promise<Job<ReindexJobData>> {
return this.jobQueue.add('full-reindex', {
forceFullReindex: true,
});
}
async queueEntityReindex(entityTypes: string[]): Promise<Job<ReindexJobData>> {
return this.jobQueue.add('entity-reindex', {
entityTypes,
});
}
private async processSearchJob(job: Job<ReindexJobData>) {
const { entityTypes, forceFullReindex } = job.data;
job.setProgress(0);
try {
if (forceFullReindex) {
await this.indexingService.fullReindex();
} else if (entityTypes) {
await this.indexingService.reindexEntityTypes(entityTypes);
} else {
await this.indexingService.incrementalUpdate();
}
job.setProgress(100);
} catch (error) {
Logger.error('Search indexing job failed', error);
throw error;
}
}
}
// packages/dashboard/plugin/service/entity-discovery.service.ts
@Injectable()
export class EntityDiscoveryService {
async discoverAllEntities(): Promise<EntityMetadata[]> {
// Auto-discover all TypeORM entities (built-in + custom)
// Extract searchable fields from entity metadata
// Handle custom fields dynamically
// Generate search configurations per entity
}
async getCustomEntityTypes(): Promise<string[]> {
// Identify plugin-defined entities
// Return entity names not in core Vendure entities list
}
}
```
### Search Configuration
```typescript
// packages/dashboard/plugin/config/search-config.ts
export interface SearchConfig {
// Search backend strategy
strategy: 'database' | 'typesense' | 'custom';
// Strategy-specific options
strategyOptions?: {
database?: DatabaseSearchOptions;
typesense?: TypeSenseSearchOptions;
custom?: any;
};
// Indexing configuration
indexing: {
fullReindexCron: string; // Default: '0 2 * * *'
incrementalUpdateCron: string; // Default: '*/15 * * * *'
batchSize: number; // Default: 1000
maxConcurrency: number; // Default: 5
};
// External content sources
externalSources: {
documentation: {
enabled: boolean;
endpoint: string;
refreshIntervalHours: number; // Default: 24
};
website: {
enabled: boolean;
endpoint: string;
refreshIntervalHours: number; // Default: 12
};
};
}
interface DatabaseSearchOptions {
// Full-text search configuration for different databases
postgresql?: {
searchConfig: string; // Default: 'english'
enableHighlighting: boolean;
};
mysql?: {
minWordLength: number;
enableBooleanMode: boolean;
};
}
interface TypeSenseSearchOptions {
nodes: Array<{
host: string;
port: number;
protocol: 'http' | 'https';
}>;
apiKey: string;
connectionTimeoutSeconds?: number;
collectionName?: string; // Default: 'vendure_search'
}
```
### Real-time Index Updates
```typescript
// packages/dashboard/plugin/service/search-event-handler.service.ts
@Injectable()
export class SearchEventHandlerService implements OnApplicationBootstrap {
constructor(
private eventBus: EventBus,
private indexingService: SearchIndexingService,
) {}
onApplicationBootstrap() {
// Listen for entity events to keep index up-to-date
this.eventBus.ofType(VendureEntityEvent).subscribe(event => {
this.handleEntityEvent(event);
});
}
private async handleEntityEvent(event: VendureEntityEvent<any>) {
const { entity, type, ctx } = event;
try {
switch (type) {
case 'created':
case 'updated':
await this.indexingService.updateEntity(
entity.constructor.name,
entity.id
);
break;
case 'deleted':
await this.indexingService.removeEntity(
entity.constructor.name,
event.entity.id
);
break;
}
} catch (error) {
Logger.error(`Failed to update search index for ${entity.constructor.name}:${entity.id}`, error);
}
}
}
```
### External Content Integration
External content is fetched from APIs and indexed locally, so search requests don't hit external services:
#### Documentation Content Service
```typescript
// packages/dashboard/plugin/service/documentation-content.service.ts
@Injectable()
export class DocumentationContentService {
private readonly DOCS_API_ENDPOINT = 'https://docs.vendure.io/api/content';
async fetchDocumentationContent(): Promise<SearchIndexEntry[]> {
try {
// Fetch all documentation pages from API
const response = await fetch(this.DOCS_API_ENDPOINT);
const docs = await response.json();
return docs.map(doc => ({
id: `docs:${doc.slug}`,
type: 'DOCUMENTATION',
title: doc.title,
subtitle: doc.category,
description: doc.excerpt,
content: doc.content, // Full searchable text
url: `https://docs.vendure.io/${doc.slug}`,
thumbnailUrl: doc.imageUrl,
metadata: {
category: doc.category,
tags: doc.tags,
lastModified: doc.updatedAt,
},
permissions: [], // Public content
channelIds: [], // Available to all channels
lastUpdated: new Date(),
}));
} catch (error) {
Logger.error('Failed to fetch documentation content', error);
return [];
}
}
}
```
#### Website Content Service
```typescript
// packages/dashboard/plugin/service/website-content.service.ts
@Injectable()
export class WebsiteContentService {
private readonly STORYBLOK_API_ENDPOINT = 'https://api.storyblok.com/v2/cdn/stories';
private readonly STORYBLOK_TOKEN = process.env.STORYBLOK_TOKEN;
private readonly VENDURE_PLUGINS_API = 'https://vendure.io/api/plugins';
async fetchBlogPosts(): Promise<SearchIndexEntry[]> {
try {
// Fetch blog posts from Storyblok CMS
const response = await fetch(
`${this.STORYBLOK_API_ENDPOINT}?starts_with=blog&token=${this.STORYBLOK_TOKEN}`
);
const { stories } = await response.json();
return stories.map(story => ({
id: `blog:${story.slug}`,
type: 'BLOG_POST',
title: story.content.title,
subtitle: `By ${story.content.author}`,
description: story.content.excerpt,
content: story.content.body, // Full blog content for search
url: `https://vendure.io/blog/${story.slug}`,
thumbnailUrl: story.content.featuredImage?.filename,
metadata: {
author: story.content.author,
publishedAt: story.published_at,
tags: story.tag_list,
readingTime: story.content.readingTime,
},
permissions: [], // Public content
channelIds: [], // Available to all channels
lastUpdated: new Date(story.published_at),
}));
} catch (error) {
Logger.error('Failed to fetch blog posts from Storyblok', error);
return [];
}
}
async fetchPlugins(): Promise<SearchIndexEntry[]> {
try {
// Fetch plugins from Vendure website API
const response = await fetch(this.VENDURE_PLUGINS_API);
const plugins = await response.json();
return plugins.map(plugin => ({
id: `plugin:${plugin.id}`,
type: 'PLUGIN',
title: plugin.name,
subtitle: `v${plugin.version} by ${plugin.author}`,
description: plugin.description,
content: `${plugin.name} ${plugin.description} ${plugin.readme}`,
url: `https://vendure.io/plugins/${plugin.slug}`,
thumbnailUrl: plugin.logoUrl,
metadata: {
author: plugin.author,
version: plugin.version,
downloads: plugin.downloads,
stars: plugin.stars,
category: plugin.category,
compatibility: plugin.vendureVersion,
},
permissions: [], // Public content
channelIds: [], // Available to all channels
lastUpdated: new Date(plugin.updatedAt),
}));
} catch (error) {
Logger.error('Failed to fetch plugins', error);
return [];
}
}
}
## Frontend Implementation
## Data Fetching
NEVER use Apollo Client or any other fetching library. We expose our own API client via `import { api } from '@/vdb/graphql/api.js';`
The API Client must be combined with Tanstack Query, mostly `useQuery` or `useMutation`.
### Integration with App Layout
Based on the current dashboard structure, the command palette should be integrated into the main app layout to be available globally. Here's how to modify the existing `app-layout.tsx`:
```typescript
// packages/dashboard/src/lib/components/layout/app-layout.tsx
import { CommandPalette } from '@/vdb/components/search/command-palette.js';
import { SearchProvider } from '@/vdb/providers/search-provider.js';
import { useKeyboardShortcuts } from '@/vdb/hooks/use-keyboard-shortcuts.js';
export function AppLayout() {
const { settings } = useUserSettings();
const { isCommandPaletteOpen, setIsCommandPaletteOpen } = useKeyboardShortcuts();
return (
<SearchProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<div className="container mx-auto">
<header className="border-b border-border flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex items-center justify-between gap-2 px-4 w-full">
<div className="flex items-center justify-start gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<GeneratedBreadcrumbs />
</div>
<div className="flex items-center justify-end gap-2">
{settings.devMode && <DevModeIndicator />}
<Alerts />
</div>
</div>
</header>
<Outlet />
</div>
</SidebarInset>
<PrereleasePopup />
{/* Global Command Palette */}
<CommandPalette
isOpen={isCommandPaletteOpen}
onOpenChange={setIsCommandPaletteOpen}
/>
</SidebarProvider>
</SearchProvider>
);
}
```
### Search Component Structure
```
src/lib/components/global-search/
├── global-search-trigger.tsx // Search button/shortcut in header
├── global-search-dialog.tsx // Main search interface
├── search-results-list.tsx // Results container
├── search-result-item.tsx // Individual result component
├── search-filters.tsx // Type/category filters
├── search-history.tsx // Recent searches
├── quick-actions-list.tsx // Quick actions display (Frontend only)
├── quick-action-item.tsx // Individual quick action
└── hooks/
├── use-global-search.ts // Search API integration
├── use-search-history.ts // Local search history
├── use-search-shortcuts.ts // Keyboard shortcuts
└── use-quick-actions.ts // Quick actions management (Frontend only)
Quick actions are managed entirely in the React application and do not go through the GraphQL API:
// src/lib/components/global-search/hooks/use-quick-actions.ts
export interface QuickAction {
id: string;
label: string;
description?: string;
icon?: string;
shortcut?: string;
isContextAware: boolean;
requiredPermissions?: string[];
handler: QuickActionHandler;
params?: Record<string, any>;
}
export type QuickActionHandler = (context: QuickActionContext) => void | Promise<void>;
export interface QuickActionContext {
// Current page context
currentRoute: string;
currentEntityType?: string;
currentEntityId?: string;
// Available utilities
navigate: (path: string) => void;
showNotification: (message: string, type?: 'success' | 'error' | 'warning') => void;
confirm: (message: string) => Promise<boolean>;
// Data access (if needed)
executeApiOperation: (query: string, variables?: any) => Promise<any>;
}
export const useQuickActions = () => {
const [globalActions, setGlobalActions] = useState<QuickAction[]>([]);
const [contextActions, setContextActions] = useState<QuickAction[]>([]);
const location = useLocation();
const navigate = useNavigate();
// Built-in global actions
const builtInGlobalActions: QuickAction[] = [
{
id: 'create-product',
label: 'Create New Product',
icon: 'plus',
shortcut: 'ctrl+shift+p',
isContextAware: false,
handler: context => context.navigate('/products/new'),
},
{
id: 'create-customer',
label: 'Create New Customer',
icon: 'user-plus',
shortcut: 'ctrl+shift+c',
isContextAware: false,
handler: context => context.navigate('/customers/new'),
},
// ... more global actions
];
// Built-in context-aware actions
const getContextActions = (route: string, entityType?: string) => {
const actions: QuickAction[] = [];
if (route.includes('/products/') && entityType === 'product') {
actions.push({
id: 'save-product',
label: 'Save Product',
icon: 'save',
shortcut: 'ctrl+s',
isContextAware: true,
handler: async context => {
// Trigger save action for current product
// This would integrate with the product form
},
});
}
return actions;
};
// Register custom actions (from defineDashboardExtensions)
const registerAction = useCallback((action: QuickAction) => {
if (action.isContextAware) {
setContextActions(prev => [...prev, action]);
} else {
setGlobalActions(prev => [...prev, action]);
}
}, []);
// Get all available actions for current context
const getAvailableActions = useMemo(() => {
const currentContextActions = getContextActions(
location.pathname,
// Extract entity type from route
location.pathname.split('/')[1],
);
return [...builtInGlobalActions, ...globalActions, ...currentContextActions, ...contextActions];
}, [location.pathname, globalActions, contextActions]);
return {
actions: getAvailableActions,
registerAction,
};
};
// packages/dashboard/src/lib/providers/search-provider.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface SearchContextType {
isCommandPaletteOpen: boolean;
setIsCommandPaletteOpen: (open: boolean) => void;
searchQuery: string;
setSearchQuery: (query: string) => void;
searchResults: SearchResult[];
setSearchResults: (results: SearchResult[]) => void;
isSearching: boolean;
setIsSearching: (searching: boolean) => void;
}
const SearchContext = createContext<SearchContextType | undefined>(undefined);
export const SearchProvider = ({ children }: { children: ReactNode }) => {
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
return (
<SearchContext.Provider
value={{
isCommandPaletteOpen,
setIsCommandPaletteOpen,
searchQuery,
setSearchQuery,
searchResults,
setSearchResults,
isSearching,
setIsSearching,
}}
>
{children}
</SearchContext.Provider>
);
};
export const useSearchContext = () => {
const context = useContext(SearchContext);
if (!context) {
throw new Error('useSearchContext must be used within SearchProvider');
}
return context;
};
// packages/dashboard/src/lib/hooks/use-global-search.ts
import { useQuery } from '@apollo/client';
import { useDebounce } from '@/vdb/hooks/use-debounce.js';
import { useSearchContext } from '@/vdb/providers/search-provider.js';
import { globalSearchDocument } from '@/vdb/graphql/search.js';
export const useGlobalSearch = () => {
const { searchQuery, setSearchResults, setIsSearching } = useSearchContext();
const debouncedQuery = useDebounce(searchQuery, 300);
const { data, loading, error } = useQuery(globalSearchDocument, {
variables: {
input: {
query: debouncedQuery,
types: [], // All types by default
limit: 20,
},
},
skip: !debouncedQuery || debouncedQuery.length < 2,
onCompleted: data => {
setSearchResults(data.globalSearch.items);
setIsSearching(false);
},
onError: () => {
setSearchResults([]);
setIsSearching(false);
},
});
// Update loading state
React.useEffect(() => {
setIsSearching(loading);
}, [loading, setIsSearching]);
return {
results: data?.globalSearch.items || [],
loading,
error,
totalCount: data?.globalSearch.totalItems || 0,
};
};
};
// Register custom actions from extensions
const registerCustomAction = (action: QuickAction) => {
if (action.isContextAware) {
setContextActions(prev => [...prev, action]);
} else {
setGlobalActions(prev => [...prev, action]);
}
};
return {
globalActions: [...builtInGlobalActions, ...globalActions],
contextActions: [...getContextActions(location.pathname), ...contextActions],
registerCustomAction,
};
};
### Integration Points
- **AppLayout Header**: Search trigger button (🔍 + Cmd/Ctrl+K)
- **Keyboard Shortcuts**: Global hotkey handling + custom action shortcuts
- **Navigation**: Direct routing to search results
- **Context Awareness**: Pre-filter based on current page + show relevant quick actions
- **Extension API**: Integration with `defineDashboardExtensions` for custom actions
- **Page Components**: Context-aware actions registration from individual pages
## User Experience Design
### Search Interface
- **Trigger**: Prominent search icon in header
- **Modal**: Full-screen overlay with cmdk-style interface
- **Input**: Auto-focus with placeholder suggestions
- **Shortcuts**: Cmd/Ctrl+K to open, Esc to close
- **Loading States**: Skeleton loaders, progressive results
### Result Categories
**Quick Actions Section (Always at Top):**
- **Global Actions** - Create entities, navigate, account actions
- **Context Actions** - Save, duplicate, delete (when on detail pages)
- **Custom Actions** - Developer-registered actions via extensions
**Search Results:**
- **Recent Items** - Recently viewed/edited entities
- **Products** - With thumbnails, SKUs, status
- **Customers** - With avatars, contact info
- **Orders** - With status badges, customer names
- **Content** - Collections, promotions, assets
- **System** - Settings, admin tools, reports
- **Help** - Documentation, tutorials
- **Blog** - Articles, guides, announcements
- **Plugins** - Community and official plugins
- **Resources** - Website content, guides
### Result Actions
**Quick Actions:**
- **Execute**: Run the action immediately (with keyboard shortcuts)
- **Parameters**: Some actions may require parameters/confirmation
**Entity Results:**
- **Primary**: Navigate to entity detail page
- **Secondary**: Quick actions (edit, duplicate, archive)
- **Contextual**: Entity-specific actions in dropdown
**External Content:**
- **Primary**: Open in new tab
- **Secondary**: Copy link, bookmark
## Technical Implementation Plan
### Phase 1: Core Infrastructure (Week 1-2)
#### 1. Backend API Setup & Indexing Infrastructure
- Create search GraphQL schema extensions
- Implement search strategy interface and database strategy
- Set up search index table/collection structure
- Implement basic search indexing service
- Create scheduled indexing tasks (full + incremental)
- Add search resolver with permission filtering
#### 2. Frontend Foundation & Quick Actions
- Create search dialog component using existing Command UI
- Implement search hook with GraphQL integration
- Add keyboard shortcut handling (global + custom action shortcuts)
- Create basic result list with entity routing
- Implement quick actions service and components
- Set up context-aware action registration system
### Phase 2: Entity Integration (Week 3-4)
#### 3. Core Entity Indexing & Search Implementation
**Initial Indexing (High Priority):**
- Implement indexing for core entities: Products, Customers, Orders, Collections
- Set up entity event handlers for real-time index updates
- Create entity-specific index entry builders
- Test search functionality with basic relevance scoring
**Extended Entity Indexing:**
- Index remaining built-in entities (Assets, Facets, Administrators, etc.)
- Implement custom field indexing
- Add support for multi-language content
- Optimize search relevance and ranking
#### 4. UI Enhancement & Built-in Quick Actions
- Result categorization and grouping with quick actions at top
- Thumbnail/avatar display for entities
- Status badges and metadata display
- Search result actions (view, edit, duplicate)
- Implement built-in global actions (create, navigate, account)
- Implement built-in context-aware actions (save, duplicate, delete)
### Phase 3: Advanced Features (Week 5-6)
#### 5. Search Optimization & Alternative Backend Support
- Implement TypeSense search strategy
- Add search highlighting and snippets
- Create search filters by entity type and metadata
- Add recent items prioritization and search history
- Performance optimization and query tuning
#### 6. Custom Actions API & Polish
- Implement `defineDashboardExtensions` API integration
- Custom action registration and validation system
- Result caching and pagination improvements
- Search analytics and usage tracking
- Accessibility improvements (ARIA, keyboard nav)
- Mobile responsive design
### Phase 4: Extended Coverage (Week 7-8)
#### 7. Complete Entity Coverage
**Remaining Built-in Entities:**
- Stock Locations, Tags, Global Settings
- History Entries, Sessions, Users
- Order Lines, Payments, Fulfillments, Refunds
- Addresses, Customer Groups
**Settings Pages Integration:**
- Global settings, channel settings, tax configuration
- System health, job queue, scheduled tasks
- Navigation menu items and quick actions
#### 8. Custom Entity & External Content Integration
**Custom Entity Discovery:**
- Auto-discovery of plugin-defined entities
- Dynamic indexing of custom fields
- Entity extension search support
**External Content Integration:**
- Documentation search API integration
- Blog posts and website content (Storyblok CMS)
- Plugin directory integration (Vendure app)
- Content caching and refresh strategies
#### 9. Advanced Search Features
- Search syntax (quotes, operators, filters)
- Saved searches and bookmarks
- Search scoping by channel/context
- Search result preview/quick view
## Performance Considerations
### Indexing Performance
- **Scheduled Indexing**: Full reindex daily, incremental updates every 15 minutes
- **Batch Processing**: Process entities in batches (default 1000) to avoid memory issues
- **Concurrent Processing**: Parallel indexing with configurable concurrency
- **Real-time Updates**: Entity events trigger immediate index updates
- **Index Optimization**: Database-specific optimizations (PostgreSQL ts_vector, MySQL FULLTEXT)
### Search Performance
- **Index-based Search**: All searches query the dedicated search index
- **Search Strategy Abstraction**: Pluggable backends (Database, TypeSense, Elasticsearch)
- **Result Caching**: Cache frequent searches client-side
- **Search Debouncing**: 300ms delay to reduce API calls
- **Pagination**: Efficient offset-based pagination with limits
- **Permission Filtering**: Index stores required permissions for efficient filtering
### Backend-Specific Optimizations
- **Database Strategy**: Leverage native full-text search (ts_rank for PostgreSQL, MATCH AGAINST for MySQL)
- **TypeSense Strategy**: Advanced features like typo tolerance, faceting, geo-search
- **Memory Usage**: Configurable batch sizes and connection pooling
- **External Content Caching**: Cache with TTL, configurable refresh intervals
## Security & Permissions
- **Permission Filtering**: Results filtered by user permissions
- **Channel Scoping**: Respect channel access restrictions
- **Sensitive Data**: Exclude private/internal information
- **Rate Limiting**: Prevent search API abuse
## Testing Strategy
- **Unit Tests**: Search service logic, result formatting
- **Integration Tests**: GraphQL API endpoints, permissions
- **E2E Tests**: Full search workflow, keyboard shortcuts
- **Performance Tests**: Search response times, large datasets
## Success Metrics
- **Usage**: Search queries per user per session
- **Performance**: Average search response time < 300ms
- **Effectiveness**: Click-through rate on search results
- **User Satisfaction**: Search success rate, user feedback
## File Structure Impact
### New Files to Create
packages/dashboard/plugin/api/search-extensions.ts packages/dashboard/plugin/service/global-search.service.ts packages/dashboard/plugin/service/search-indexing.service.ts packages/dashboard/plugin/service/search-job.service.ts packages/dashboard/plugin/service/search-event-handler.service.ts packages/dashboard/plugin/service/entity-discovery.service.ts packages/dashboard/plugin/service/documentation-content.service.ts packages/dashboard/plugin/service/website-content.service.ts packages/dashboard/plugin/strategies/database-search.strategy.ts packages/dashboard/plugin/strategies/typesense-search.strategy.ts packages/dashboard/plugin/tasks/search-index-task.ts packages/dashboard/plugin/resolver/global-search.resolver.ts packages/dashboard/plugin/config/search-config.ts packages/dashboard/src/lib/components/global-search/ ├── global-search-trigger.tsx ├── global-search-dialog.tsx ├── search-results-list.tsx ├── search-result-item.tsx ├── search-filters.tsx ├── search-history.tsx ├── quick-actions-list.tsx ├── quick-action-item.tsx └── hooks/ ├── use-global-search.ts ├── use-search-history.ts ├── use-search-shortcuts.ts └── use-quick-actions.ts
### Files to Modify
- `packages/dashboard/plugin/dashboard.plugin.ts` - Add search resolver and scheduled tasks
- `packages/dashboard/src/lib/components/layout/app-layout.tsx` - Add search trigger
- `packages/dashboard/plugin/api/api-extensions.ts` - Include search schema
- `packages/dashboard/src/lib/framework/extension-api/` - Add quick actions to extension API
### Database Migration
```sql
-- Migration for search index table (PostgreSQL example)
CREATE TABLE search_index (
id VARCHAR(255) PRIMARY KEY,
type VARCHAR(50) NOT NULL,
title VARCHAR(500) NOT NULL,
subtitle VARCHAR(500),
description TEXT,
content TEXT NOT NULL,
url VARCHAR(1000) NOT NULL,
thumbnail_url VARCHAR(1000),
metadata JSONB,
permissions VARCHAR(100)[] DEFAULT '{}',
channel_ids VARCHAR(36)[] DEFAULT '{}',
entity_id VARCHAR(36),
last_updated TIMESTAMP NOT NULL DEFAULT NOW(),
search_vector tsvector,
relevance_score FLOAT DEFAULT 0
);
-- Indexes for performance
CREATE INDEX idx_search_index_type ON search_index(type);
CREATE INDEX idx_search_index_permissions ON search_index USING GIN(permissions);
CREATE INDEX idx_search_index_channels ON search_index USING GIN(channel_ids);
CREATE INDEX idx_search_index_updated ON search_index(last_updated);
CREATE INDEX idx_search_index_fts ON search_index USING GIN(search_vector);
-- Trigger to automatically update search_vector
CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector := to_tsvector('english',
COALESCE(NEW.title, '') || ' ' ||
COALESCE(NEW.subtitle, '') || ' ' ||
COALESCE(NEW.description, '') || ' ' ||
COALESCE(NEW.content, '')
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER search_vector_update
BEFORE INSERT OR UPDATE ON search_index
FOR EACH ROW EXECUTE FUNCTION update_search_vector();
// In vendure-config.ts
import { DashboardPlugin } from '@vendure/dashboard/plugin';
import {
searchFullReindexTask,
searchIncrementalUpdateTask,
searchExternalContentRefreshTask,
} from '@vendure/dashboard/plugin/tasks/search-index-task';
const config: VendureConfig = {
plugins: [
DashboardPlugin.init({
route: 'dashboard',
appDir: './dist/dashboard',
search: {
strategy: 'database',
indexing: {
fullReindexCron: '0 2 * * *', // Daily at 2 AM
incrementalUpdateCron: '*/15 * * * *', // Every 15 minutes
},
externalSources: {
documentation: {
enabled: true,
endpoint: 'https://docs.vendure.io/api/search',
refreshIntervalHours: 24,
},
website: {
enabled: true,
endpoint: 'https://vendure.io/api/search',
refreshIntervalHours: 12,
},
},
},
}),
],
scheduledTasks: [searchFullReindexTask, searchIncrementalUpdateTask],
};
const config: VendureConfig = {
plugins: [
DashboardPlugin.init({
route: 'dashboard',
appDir: './dist/dashboard',
search: {
strategy: 'typesense',
strategyOptions: {
typesense: {
nodes: [
{
host: 'localhost',
port: 8108,
protocol: 'http',
},
],
apiKey: 'xyz123',
collectionName: 'vendure_search',
},
},
// ... other config
},
}),
],
};
// Extension API for registering custom quick actions
interface QuickActionExtension {
// Global actions (available everywhere)
globalActions?: QuickActionDefinition[];
// Context-aware actions (route/page specific)
contextActions?: {
[routePattern: string]: QuickActionDefinition[];
};
// Entity-specific actions (based on entity type)
entityActions?: {
[entityType: string]: QuickActionDefinition[];
};
}
interface QuickActionDefinition {
id: string;
label: string;
description?: string;
icon?: string;
shortcut?: string;
requiredPermissions?: string[];
handler: QuickActionHandler;
params?: Record<string, any>;
}
type QuickActionHandler = (context: QuickActionContext) => void | Promise<void>;
interface QuickActionContext {
// Current page context
currentRoute: string;
currentEntityType?: string;
currentEntityId?: string;
// Available utilities
navigate: (path: string) => void;
showNotification: (message: string, type?: 'success' | 'error' | 'warning') => void;
confirm: (message: string) => Promise<boolean>;
// Data access (if needed)
executeGraphQL: (query: string, variables?: any) => Promise<any>;
}
// In a plugin or custom extension
defineDashboardExtensions({
globalActions: [
{
id: 'export-all-orders',
label: 'Export All Orders',
description: 'Export all orders to CSV',
icon: 'download',
shortcut: 'ctrl+shift+e',
requiredPermissions: ['ReadOrder'],
handler: async context => {
const confirmed = await context.confirm('Export all orders to CSV?');
if (confirmed) {
// Implementation here
context.showNotification('Export started', 'success');
}
},
},
],
});
defineDashboardExtensions({
contextActions: {
// Only show on product detail pages
'/products/:id': [
{
id: 'sync-inventory',
label: 'Sync Inventory',
description: 'Sync product inventory with external system',
icon: 'refresh',
shortcut: 'ctrl+shift+s',
handler: async context => {
if (context.currentEntityId) {
// Sync specific product
await syncProductInventory(context.currentEntityId);
context.showNotification('Inventory synced', 'success');
}
},
},
],
// Show on any list page
'/**/list': [
{
id: 'bulk-export',
label: 'Bulk Export',
icon: 'download',
handler: context => {
context.navigate('/export?type=' + context.currentEntityType);
},
},
],
},
});
ctrl+shift+p - Navigate to product creationctrl+shift+o - Navigate to order creationctrl+shift+c - Navigate to customer creationctrl+shift+u - Navigate to user profilectrl+o - Show order search dialogProduct Detail Page:
ctrl+s - Save current productctrl+d - Duplicate current productctrl+shift+v - Add new product variantOrder Detail Page:
ctrl+f - Open fulfillment dialogctrl+shift+x - Cancel order with confirmationctrl+p - Add payment to orderAny List Page:
ctrl+e - Export current filtered datactrl+b - Enter bulk edit modectrl+n - Create new item of current typeThis document is a living plan and should be updated as the implementation progresses and requirements evolve.