feat: Add category filter to search interface
This commit is contained in:
@@ -361,13 +361,63 @@ async def get_integrations():
|
||||
|
||||
@app.get("/api/categories")
|
||||
async def get_categories():
|
||||
"""Get available service categories for filtering."""
|
||||
"""Get available workflow categories for filtering."""
|
||||
try:
|
||||
categories = db.get_service_categories()
|
||||
return {"categories": categories}
|
||||
# Try to load from the generated unique categories file
|
||||
categories_file = Path("context/unique_categories.json")
|
||||
if categories_file.exists():
|
||||
with open(categories_file, 'r', encoding='utf-8') as f:
|
||||
categories = json.load(f)
|
||||
return {"categories": categories}
|
||||
else:
|
||||
# Fallback: extract categories from search_categories.json
|
||||
search_categories_file = Path("context/search_categories.json")
|
||||
if search_categories_file.exists():
|
||||
with open(search_categories_file, 'r', encoding='utf-8') as f:
|
||||
search_data = json.load(f)
|
||||
|
||||
unique_categories = set()
|
||||
for item in search_data:
|
||||
if item.get('category'):
|
||||
unique_categories.add(item['category'])
|
||||
else:
|
||||
unique_categories.add('Uncategorized')
|
||||
|
||||
categories = sorted(list(unique_categories))
|
||||
return {"categories": categories}
|
||||
else:
|
||||
# Last resort: return basic categories
|
||||
return {"categories": ["Uncategorized"]}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading categories: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error fetching categories: {str(e)}")
|
||||
|
||||
@app.get("/api/category-mappings")
|
||||
async def get_category_mappings():
|
||||
"""Get filename to category mappings for client-side filtering."""
|
||||
try:
|
||||
search_categories_file = Path("context/search_categories.json")
|
||||
if not search_categories_file.exists():
|
||||
return {"mappings": {}}
|
||||
|
||||
with open(search_categories_file, 'r', encoding='utf-8') as f:
|
||||
search_data = json.load(f)
|
||||
|
||||
# Convert to a simple filename -> category mapping
|
||||
mappings = {}
|
||||
for item in search_data:
|
||||
filename = item.get('filename')
|
||||
category = item.get('category') or 'Uncategorized'
|
||||
if filename:
|
||||
mappings[filename] = category
|
||||
|
||||
return {"mappings": mappings}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading category mappings: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error fetching category mappings: {str(e)}")
|
||||
|
||||
@app.get("/api/workflows/category/{category}", response_model=SearchResponse)
|
||||
async def search_workflows_by_category(
|
||||
category: str,
|
||||
|
||||
@@ -75,6 +75,25 @@ def main():
|
||||
|
||||
print(f"Generated search_categories.json with {len(search_categories)} entries")
|
||||
|
||||
# Generate unique categories list for API
|
||||
unique_categories = set()
|
||||
for item in search_categories:
|
||||
if item['category']:
|
||||
unique_categories.add(item['category'])
|
||||
|
||||
# Always include 'Uncategorized' for workflows without categories
|
||||
unique_categories.add('Uncategorized')
|
||||
|
||||
# Sort categories alphabetically
|
||||
categories_list = sorted(list(unique_categories))
|
||||
|
||||
# Write unique categories to a separate file for API consumption
|
||||
categories_output_path = Path("context/unique_categories.json")
|
||||
with open(categories_output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(categories_list, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"Generated unique_categories.json with {len(categories_list)} categories")
|
||||
|
||||
# Print some statistics
|
||||
categorized = sum(1 for item in search_categories if item['category'])
|
||||
uncategorized = len(search_categories) - categorized
|
||||
|
||||
@@ -302,6 +302,16 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workflow-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
@@ -623,6 +633,14 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="categoryFilter">Category:</label>
|
||||
<select id="categoryFilter">
|
||||
<option value="all">All Categories</option>
|
||||
<!-- Categories will be populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>
|
||||
<input type="checkbox" id="activeOnly">
|
||||
@@ -747,14 +765,18 @@
|
||||
filters: {
|
||||
trigger: 'all',
|
||||
complexity: 'all',
|
||||
category: 'all',
|
||||
activeOnly: false
|
||||
}
|
||||
},
|
||||
categories: [],
|
||||
categoryMap: new Map()
|
||||
};
|
||||
|
||||
this.elements = {
|
||||
searchInput: document.getElementById('searchInput'),
|
||||
triggerFilter: document.getElementById('triggerFilter'),
|
||||
complexityFilter: document.getElementById('complexityFilter'),
|
||||
categoryFilter: document.getElementById('categoryFilter'),
|
||||
activeOnlyFilter: document.getElementById('activeOnly'),
|
||||
themeToggle: document.getElementById('themeToggle'),
|
||||
resultsCount: document.getElementById('resultsCount'),
|
||||
@@ -839,6 +861,16 @@
|
||||
this.resetAndSearch();
|
||||
});
|
||||
|
||||
this.elements.categoryFilter.addEventListener('change', (e) => {
|
||||
const selectedCategory = e.target.value;
|
||||
console.log(`Category filter changed to: ${selectedCategory}`);
|
||||
console.log('Current category map size:', this.state.categoryMap.size);
|
||||
|
||||
this.state.filters.category = selectedCategory;
|
||||
this.state.currentPage = 1;
|
||||
this.resetAndSearch();
|
||||
});
|
||||
|
||||
this.elements.activeOnlyFilter.addEventListener('change', (e) => {
|
||||
this.state.filters.activeOnly = e.target.checked;
|
||||
this.state.currentPage = 1;
|
||||
@@ -942,18 +974,96 @@
|
||||
this.showState('loading');
|
||||
|
||||
try {
|
||||
// Load categories first, then stats and workflows
|
||||
console.log('Loading categories...');
|
||||
await this.loadCategories();
|
||||
|
||||
console.log('Categories loaded, populating filter...');
|
||||
this.populateCategoryFilter();
|
||||
|
||||
// Load stats and workflows in parallel
|
||||
const [stats, workflows] = await Promise.all([
|
||||
console.log('Loading stats and workflows...');
|
||||
const [stats] = await Promise.all([
|
||||
this.apiCall('/stats'),
|
||||
this.loadWorkflows(true)
|
||||
]);
|
||||
|
||||
this.updateStatsDisplay(stats);
|
||||
console.log('Initial data loading complete');
|
||||
} catch (error) {
|
||||
console.error('Error during initial data loading:', error);
|
||||
this.showError('Failed to load data: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async loadCategories() {
|
||||
try {
|
||||
console.log('Loading categories from API...');
|
||||
|
||||
// Load categories and mappings in parallel from API
|
||||
const [categoriesResponse, mappingsResponse] = await Promise.all([
|
||||
this.apiCall('/categories'),
|
||||
this.apiCall('/category-mappings')
|
||||
]);
|
||||
|
||||
// Set categories from API
|
||||
this.state.categories = categoriesResponse.categories || ['Uncategorized'];
|
||||
|
||||
// Build category map from API mappings
|
||||
const categoryMap = new Map();
|
||||
const mappings = mappingsResponse.mappings || {};
|
||||
|
||||
Object.entries(mappings).forEach(([filename, category]) => {
|
||||
categoryMap.set(filename, category || 'Uncategorized');
|
||||
});
|
||||
|
||||
this.state.categoryMap = categoryMap;
|
||||
|
||||
console.log(`Successfully loaded ${this.state.categories.length} categories from API:`, this.state.categories);
|
||||
console.log(`Loaded ${categoryMap.size} category mappings from API`);
|
||||
|
||||
return { categories: this.state.categories, mappings: mappings };
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories from API:', error);
|
||||
// Set default categories if loading fails
|
||||
this.state.categories = ['Uncategorized'];
|
||||
this.state.categoryMap = new Map();
|
||||
return { categories: this.state.categories, mappings: {} };
|
||||
}
|
||||
}
|
||||
|
||||
populateCategoryFilter() {
|
||||
const select = this.elements.categoryFilter;
|
||||
|
||||
if (!select) {
|
||||
console.error('Category filter element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Populating category filter with:', this.state.categories);
|
||||
|
||||
// Clear existing options except "All Categories"
|
||||
while (select.children.length > 1) {
|
||||
select.removeChild(select.lastChild);
|
||||
}
|
||||
|
||||
if (this.state.categories.length === 0) {
|
||||
console.warn('No categories available to populate filter');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add categories in alphabetical order
|
||||
this.state.categories.forEach(category => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category;
|
||||
option.textContent = category;
|
||||
select.appendChild(option);
|
||||
console.log(`Added category option: ${category}`);
|
||||
});
|
||||
|
||||
console.log(`Category filter populated with ${select.options.length - 1} categories`);
|
||||
}
|
||||
|
||||
async loadWorkflows(reset = false) {
|
||||
if (reset) {
|
||||
this.state.currentPage = 1;
|
||||
@@ -963,25 +1073,65 @@
|
||||
this.state.isLoading = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: this.state.searchQuery,
|
||||
trigger: this.state.filters.trigger,
|
||||
complexity: this.state.filters.complexity,
|
||||
active_only: this.state.filters.activeOnly,
|
||||
page: this.state.currentPage,
|
||||
per_page: this.state.perPage
|
||||
});
|
||||
|
||||
const response = await this.apiCall(`/workflows?${params}`);
|
||||
|
||||
if (reset) {
|
||||
this.state.workflows = response.workflows;
|
||||
// If category filtering is active, we need to load all workflows to filter properly
|
||||
const needsAllWorkflows = this.state.filters.category !== 'all' && reset;
|
||||
|
||||
let allWorkflows = [];
|
||||
let totalCount = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
if (needsAllWorkflows) {
|
||||
// Load all workflows in batches for category filtering
|
||||
console.log('Loading all workflows for category filtering...');
|
||||
allWorkflows = await this.loadAllWorkflowsForCategoryFiltering();
|
||||
|
||||
// Apply client-side category filtering
|
||||
console.log(`Filtering ${allWorkflows.length} workflows for category: ${this.state.filters.category}`);
|
||||
console.log('Category map size:', this.state.categoryMap.size);
|
||||
|
||||
let matchCount = 0;
|
||||
const filteredWorkflows = allWorkflows.filter(workflow => {
|
||||
const workflowCategory = this.getWorkflowCategory(workflow.filename);
|
||||
const matches = workflowCategory === this.state.filters.category;
|
||||
|
||||
// Debug: log first few matches/non-matches
|
||||
if (matchCount < 5 || (!matches && matchCount < 3)) {
|
||||
console.log(`${workflow.filename}: ${workflowCategory} ${matches ? '===' : '!=='} ${this.state.filters.category}`);
|
||||
}
|
||||
|
||||
if (matches) matchCount++;
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
console.log(`Filtered from ${allWorkflows.length} to ${filteredWorkflows.length} workflows`);
|
||||
allWorkflows = filteredWorkflows;
|
||||
totalCount = filteredWorkflows.length;
|
||||
totalPages = 1; // All results loaded, no pagination needed
|
||||
} else {
|
||||
this.state.workflows.push(...response.workflows);
|
||||
// Normal pagination
|
||||
const params = new URLSearchParams({
|
||||
q: this.state.searchQuery,
|
||||
trigger: this.state.filters.trigger,
|
||||
complexity: this.state.filters.complexity,
|
||||
active_only: this.state.filters.activeOnly,
|
||||
page: this.state.currentPage,
|
||||
per_page: this.state.perPage
|
||||
});
|
||||
|
||||
const response = await this.apiCall(`/workflows?${params}`);
|
||||
allWorkflows = response.workflows;
|
||||
totalCount = response.total;
|
||||
totalPages = response.pages;
|
||||
}
|
||||
|
||||
this.state.totalCount = response.total;
|
||||
this.state.totalPages = response.pages;
|
||||
if (reset) {
|
||||
this.state.workflows = allWorkflows;
|
||||
this.state.totalCount = totalCount;
|
||||
this.state.totalPages = totalPages;
|
||||
} else {
|
||||
this.state.workflows.push(...allWorkflows);
|
||||
}
|
||||
|
||||
this.updateUI();
|
||||
|
||||
@@ -992,6 +1142,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
async loadAllWorkflowsForCategoryFiltering() {
|
||||
const allWorkflows = [];
|
||||
let currentPage = 1;
|
||||
const maxPerPage = 100; // API limit
|
||||
|
||||
while (true) {
|
||||
const params = new URLSearchParams({
|
||||
q: this.state.searchQuery,
|
||||
trigger: this.state.filters.trigger,
|
||||
complexity: this.state.filters.complexity,
|
||||
active_only: this.state.filters.activeOnly,
|
||||
page: currentPage,
|
||||
per_page: maxPerPage
|
||||
});
|
||||
|
||||
const response = await this.apiCall(`/workflows?${params}`);
|
||||
allWorkflows.push(...response.workflows);
|
||||
|
||||
console.log(`Loaded page ${currentPage}/${response.pages} (${response.workflows.length} workflows)`);
|
||||
|
||||
if (currentPage >= response.pages) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
console.log(`Loaded total of ${allWorkflows.length} workflows for filtering`);
|
||||
return allWorkflows;
|
||||
}
|
||||
|
||||
getWorkflowCategory(filename) {
|
||||
const category = this.state.categoryMap.get(filename);
|
||||
const result = category && category.trim() ? category : 'Uncategorized';
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadMoreWorkflows() {
|
||||
if (this.state.currentPage >= this.state.totalPages) return;
|
||||
|
||||
@@ -1025,12 +1212,19 @@
|
||||
updateResultsCount() {
|
||||
const count = this.state.totalCount;
|
||||
const query = this.state.searchQuery;
|
||||
|
||||
if (query) {
|
||||
this.elements.resultsCount.textContent = `${count.toLocaleString()} workflows found for "${query}"`;
|
||||
} else {
|
||||
this.elements.resultsCount.textContent = `${count.toLocaleString()} workflows`;
|
||||
const category = this.state.filters.category;
|
||||
|
||||
let text = `${count.toLocaleString()} workflows`;
|
||||
|
||||
if (query && category !== 'all') {
|
||||
text += ` found for "${query}" in "${category}"`;
|
||||
} else if (query) {
|
||||
text += ` found for "${query}"`;
|
||||
} else if (category !== 'all') {
|
||||
text += ` in "${category}"`;
|
||||
}
|
||||
|
||||
this.elements.resultsCount.textContent = text;
|
||||
}
|
||||
|
||||
renderWorkflows() {
|
||||
@@ -1048,6 +1242,7 @@
|
||||
createWorkflowCard(workflow) {
|
||||
const statusClass = workflow.active ? 'status-active' : 'status-inactive';
|
||||
const complexityClass = `complexity-${workflow.complexity}`;
|
||||
const category = this.getWorkflowCategory(workflow.filename);
|
||||
|
||||
const integrations = workflow.integrations.slice(0, 5).map(integration =>
|
||||
`<span class="integration-tag">${this.escapeHtml(integration)}</span>`
|
||||
@@ -1064,6 +1259,7 @@
|
||||
<div class="status-dot ${statusClass}"></div>
|
||||
<div class="complexity-dot ${complexityClass}"></div>
|
||||
<span>${workflow.node_count} nodes</span>
|
||||
<span class="category-badge">${this.escapeHtml(category)}</span>
|
||||
</div>
|
||||
<span class="trigger-badge">${this.escapeHtml(workflow.trigger_type)}</span>
|
||||
</div>
|
||||
@@ -1090,12 +1286,14 @@
|
||||
this.elements.modalDescription.textContent = workflow.description;
|
||||
|
||||
// Update stats
|
||||
const category = this.getWorkflowCategory(workflow.filename);
|
||||
this.elements.modalStats.innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem;">
|
||||
<div><strong>Status:</strong> ${workflow.active ? 'Active' : 'Inactive'}</div>
|
||||
<div><strong>Trigger:</strong> ${workflow.trigger_type}</div>
|
||||
<div><strong>Complexity:</strong> ${workflow.complexity}</div>
|
||||
<div><strong>Nodes:</strong> ${workflow.node_count}</div>
|
||||
<div><strong>Category:</strong> ${this.escapeHtml(category)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user