Merge pull request #6985 from coollabsio/filter-services-by-category
feat: add category filter dropdown to service selection
This commit is contained in:
commit
bfa40214ce
2 changed files with 106 additions and 10 deletions
|
|
@ -102,6 +102,36 @@ public function loadServices()
|
|||
: asset($default_logo),
|
||||
] + (array) $service;
|
||||
})->all();
|
||||
|
||||
// Extract unique categories from services
|
||||
$categories = collect($services)
|
||||
->pluck('category')
|
||||
->filter()
|
||||
->unique()
|
||||
->map(function ($category) {
|
||||
// Handle multiple categories separated by comma
|
||||
if (str_contains($category, ',')) {
|
||||
return collect(explode(',', $category))->map(fn ($cat) => trim($cat));
|
||||
}
|
||||
|
||||
return [$category];
|
||||
})
|
||||
->flatten()
|
||||
->unique()
|
||||
->map(function ($category) {
|
||||
// Format common acronyms to uppercase
|
||||
$acronyms = ['ai', 'api', 'ci', 'cd', 'cms', 'crm', 'erp', 'iot', 'vpn', 'vps', 'dns', 'ssl', 'tls', 'ssh', 'ftp', 'http', 'https', 'smtp', 'imap', 'pop3', 'sql', 'nosql', 'json', 'xml', 'yaml', 'csv', 'pdf', 'sms', 'mfa', '2fa', 'oauth', 'saml', 'jwt', 'rest', 'soap', 'grpc', 'graphql', 'websocket', 'webrtc', 'p2p', 'b2b', 'b2c', 'seo', 'sem', 'ppc', 'roi', 'kpi', 'ui', 'ux', 'ide', 'sdk', 'api', 'cli', 'gui', 'cdn', 'ddos', 'dos', 'xss', 'csrf', 'sqli', 'rce', 'lfi', 'rfi', 'ssrf', 'xxe', 'idor', 'owasp', 'gdpr', 'hipaa', 'pci', 'dss', 'iso', 'nist', 'cve', 'cwe', 'cvss'];
|
||||
$lower = strtolower($category);
|
||||
|
||||
if (in_array($lower, $acronyms)) {
|
||||
return strtoupper($category);
|
||||
}
|
||||
|
||||
return $category;
|
||||
})
|
||||
->sort(SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values()
|
||||
->all();
|
||||
$gitBasedApplications = [
|
||||
[
|
||||
'id' => 'public',
|
||||
|
|
@ -202,6 +232,7 @@ public function loadServices()
|
|||
|
||||
return [
|
||||
'services' => $services,
|
||||
'categories' => $categories,
|
||||
'gitBasedApplications' => $gitBasedApplications,
|
||||
'dockerBasedApplications' => $dockerBasedApplications,
|
||||
'databases' => $databases,
|
||||
|
|
|
|||
|
|
@ -13,9 +13,55 @@
|
|||
<div x-data="searchResources()">
|
||||
@if ($current_step === 'type')
|
||||
<div x-init="window.addEventListener('scroll', () => isSticky = window.pageYOffset > 100)" class="sticky z-10 top-10 py-2">
|
||||
<input autocomplete="off" x-ref="searchInput" class="input-sticky"
|
||||
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
|
||||
@keydown.window.slash.prevent="$refs.searchInput.focus()">
|
||||
<div class="flex gap-2 items-start">
|
||||
<input autocomplete="off" x-ref="searchInput" class="input-sticky flex-1"
|
||||
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
|
||||
@keydown.window.slash.prevent="$refs.searchInput.focus()">
|
||||
<!-- Category Filter Dropdown -->
|
||||
<div class="relative" x-data="{ openCategoryDropdown: false, categorySearch: '' }" @click.outside="openCategoryDropdown = false">
|
||||
<!-- Loading/Disabled State -->
|
||||
<div x-show="loading || categories.length === 0"
|
||||
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-neutral-100 dark:bg-coolgray-200 cursor-not-allowed whitespace-nowrap opacity-50">
|
||||
<span class="text-sm text-neutral-400 dark:text-neutral-600">Filter by category</span>
|
||||
<svg class="w-4 h-4 text-neutral-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Active State -->
|
||||
<div x-show="!loading && categories.length > 0"
|
||||
@click="openCategoryDropdown = !openCategoryDropdown; $nextTick(() => { if (openCategoryDropdown) $refs.categorySearchInput.focus() })"
|
||||
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-pointer hover:ring-coolgray-400 transition-all whitespace-nowrap">
|
||||
<span class="text-sm truncate" x-text="selectedCategory === '' ? 'Filter by category' : selectedCategory" :class="selectedCategory === '' ? 'text-neutral-400 dark:text-neutral-600' : 'capitalize text-black dark:text-white'"></span>
|
||||
<svg class="w-4 h-4 transition-transform text-neutral-400 shrink-0" :class="{ 'rotate-180': openCategoryDropdown }" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Dropdown Menu -->
|
||||
<div x-show="openCategoryDropdown" x-transition
|
||||
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg overflow-hidden">
|
||||
<div class="sticky top-0 p-2 bg-white dark:bg-coolgray-100 border-b border-neutral-300 dark:border-coolgray-400">
|
||||
<input type="text" x-ref="categorySearchInput" x-model="categorySearch" placeholder="Search categories..."
|
||||
class="w-full px-2 py-1 text-sm rounded border border-neutral-300 dark:border-coolgray-400 bg-white dark:bg-coolgray-200 focus:outline-none focus:ring-2 focus:ring-coolgray-400"
|
||||
@click.stop>
|
||||
</div>
|
||||
<div class="max-h-60 overflow-auto scrollbar">
|
||||
<div @click="selectedCategory = ''; categorySearch = ''; openCategoryDropdown = false"
|
||||
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200"
|
||||
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === '' }">
|
||||
<span class="text-sm">All Categories</span>
|
||||
</div>
|
||||
<template x-for="category in categories.filter(cat => categorySearch === '' || cat.toLowerCase().includes(categorySearch.toLowerCase()))" :key="category">
|
||||
<div @click="selectedCategory = category; categorySearch = ''; openCategoryDropdown = false"
|
||||
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 capitalize"
|
||||
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === category }">
|
||||
<span class="text-sm" x-text="category"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="loading">Loading...</div>
|
||||
<div x-show="!loading" class="flex flex-col gap-4 py-4">
|
||||
|
|
@ -140,6 +186,8 @@ function sortFn(a, b) {
|
|||
function searchResources() {
|
||||
return {
|
||||
search: '',
|
||||
selectedCategory: '',
|
||||
categories: [],
|
||||
loading: false,
|
||||
isSticky: false,
|
||||
selecting: false,
|
||||
|
|
@ -156,11 +204,13 @@ function searchResources() {
|
|||
this.loading = true;
|
||||
const {
|
||||
services,
|
||||
categories,
|
||||
gitBasedApplications,
|
||||
dockerBasedApplications,
|
||||
databases
|
||||
} = await this.$wire.loadServices();
|
||||
this.services = services;
|
||||
this.categories = categories || [];
|
||||
this.gitBasedApplications = gitBasedApplications;
|
||||
this.dockerBasedApplications = dockerBasedApplications;
|
||||
this.databases = databases;
|
||||
|
|
@ -171,15 +221,30 @@ function searchResources() {
|
|||
},
|
||||
filterAndSort(items, isSort = true) {
|
||||
const searchLower = this.search.trim().toLowerCase();
|
||||
let filtered = Object.values(items);
|
||||
|
||||
if (searchLower === '') {
|
||||
return isSort ? Object.values(items).sort(sortFn) : Object.values(items);
|
||||
// Filter by category if selected
|
||||
if (this.selectedCategory !== '') {
|
||||
const selectedCategoryLower = this.selectedCategory.toLowerCase();
|
||||
filtered = filtered.filter(item => {
|
||||
if (!item.category) return false;
|
||||
// Handle comma-separated categories
|
||||
const categories = item.category.includes(',')
|
||||
? item.category.split(',').map(c => c.trim().toLowerCase())
|
||||
: [item.category.toLowerCase()];
|
||||
return categories.includes(selectedCategoryLower);
|
||||
});
|
||||
}
|
||||
const filtered = Object.values(items).filter(item => {
|
||||
return (item.name?.toLowerCase().includes(searchLower) ||
|
||||
item.description?.toLowerCase().includes(searchLower) ||
|
||||
item.slogan?.toLowerCase().includes(searchLower))
|
||||
})
|
||||
|
||||
// Filter by search term
|
||||
if (searchLower !== '') {
|
||||
filtered = filtered.filter(item => {
|
||||
return (item.name?.toLowerCase().includes(searchLower) ||
|
||||
item.description?.toLowerCase().includes(searchLower) ||
|
||||
item.slogan?.toLowerCase().includes(searchLower))
|
||||
});
|
||||
}
|
||||
|
||||
return isSort ? filtered.sort(sortFn) : filtered;
|
||||
},
|
||||
get filteredGitBasedApplications() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue