Skip to content

Commit 4cbaeea

Browse files
authored
Merge pull request #153 from cakephp/feat/autocomplete-and-login-ui
Add autocomplete search and improve navbar UI
2 parents 741d874 + 5752509 commit 4cbaeea

File tree

17 files changed

+951
-143
lines changed

17 files changed

+951
-143
lines changed
-8.51 KB
Binary file not shown.

config/routes.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,14 @@
5050
$routes->setRouteClass(DashedRoute::class);
5151

5252
$routes->scope('/', function (RouteBuilder $builder): void {
53+
$builder->setExtensions(['json']);
5354
/*
5455
* Here, we are connecting '/' (base path) to a controller called 'Pages',
5556
* its action called 'display', and we pass a param to select the view file
5657
* to use (in this case, templates/Pages/home.php)...
5758
*/
5859
$builder->connect('/', ['controller' => 'Packages', 'action' => 'index']);
60+
$builder->connect('/autocomplete', ['controller' => 'Packages', 'action' => 'autocomplete']);
5961
$builder->connect('/requirements', ['controller' => 'Pages', 'action' => 'display', 'requirements']);
6062

6163
/*

package-lock.json

Lines changed: 26 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"build": "vite build"
1212
},
1313
"dependencies": {
14+
"alpinejs": "^3.15.11",
1415
"htmx.org": "^2.0.8",
1516
"slim-select": "^3.4.3",
1617
"swiper": "^12.1.3"

resources/css/style.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
--color-cake-blue: #2F85AE;
5252
}
5353

54+
[x-cloak] {
55+
display: none !important;
56+
}
57+
5458
/* Styling for default app homepage */
5559

5660
.bullet:before {

resources/js/app.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,136 @@ import Swiper from 'swiper'
22
import { Autoplay, Navigation, Pagination } from 'swiper/modules'
33
import htmx from 'htmx.org'
44
import SlimSelect from 'slim-select'
5+
import Alpine from 'alpinejs'
56

67
window.htmx = htmx
78

9+
Alpine.data('packageSearch', () => ({
10+
query: '',
11+
results: [],
12+
open: false,
13+
loading: false,
14+
selectedIndex: -1,
15+
abortController: null,
16+
debounceTimer: null,
17+
18+
init() {
19+
// Sync initial value from the input
20+
this.query = this.$refs.input?.value || ''
21+
22+
this.$watch('query', (value) => {
23+
this.debouncedFetch(value)
24+
})
25+
},
26+
27+
debouncedFetch(value) {
28+
clearTimeout(this.debounceTimer)
29+
30+
if (this.abortController) {
31+
this.abortController.abort()
32+
this.abortController = null
33+
}
34+
35+
if (value.trim().length < 2) {
36+
this.results = []
37+
this.open = false
38+
this.loading = false
39+
return
40+
}
41+
42+
this.loading = true
43+
44+
this.debounceTimer = setTimeout(() => {
45+
this.fetchResults(value.trim())
46+
}, 250)
47+
},
48+
49+
async fetchResults(q) {
50+
if (this.abortController) {
51+
this.abortController.abort()
52+
}
53+
54+
this.abortController = new AbortController()
55+
56+
try {
57+
const response = await fetch(`/autocomplete?q=${encodeURIComponent(q)}`, {
58+
signal: this.abortController.signal,
59+
headers: { 'Accept': 'application/json' },
60+
})
61+
62+
const data = await response.json()
63+
this.results = data
64+
this.open = data.length > 0
65+
this.selectedIndex = -1
66+
} catch (e) {
67+
if (e.name !== 'AbortError') {
68+
this.results = []
69+
this.open = false
70+
}
71+
} finally {
72+
this.loading = false
73+
}
74+
},
75+
76+
close() {
77+
this.open = false
78+
this.selectedIndex = -1
79+
},
80+
81+
onKeydown(e) {
82+
if (!this.open) return
83+
84+
switch (e.key) {
85+
case 'ArrowDown':
86+
e.preventDefault()
87+
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1)
88+
this.scrollToSelected()
89+
break
90+
case 'ArrowUp':
91+
e.preventDefault()
92+
this.selectedIndex = Math.max(this.selectedIndex - 1, -1)
93+
this.scrollToSelected()
94+
break
95+
case 'Enter':
96+
if (this.selectedIndex >= 0) {
97+
e.preventDefault()
98+
this.selectResult(this.results[this.selectedIndex])
99+
}
100+
break
101+
case 'Escape':
102+
this.close()
103+
break
104+
}
105+
},
106+
107+
scrollToSelected() {
108+
this.$nextTick(() => {
109+
const el = this.$refs.listbox?.querySelector('[aria-selected="true"]')
110+
el?.scrollIntoView({ block: 'nearest' })
111+
})
112+
},
113+
114+
selectResult(result) {
115+
try {
116+
const url = new URL(result.repo_url)
117+
if (url.protocol === 'https:') {
118+
window.open(url.toString(), '_blank', 'noopener,noreferrer')
119+
}
120+
} catch {
121+
// Invalid URL, ignore
122+
}
123+
this.close()
124+
},
125+
126+
127+
128+
formatNumber(num) {
129+
if (num >= 1000000) return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'
130+
if (num >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
131+
return String(num)
132+
},
133+
}))
134+
8135
const initializeSelects = (root = document) => {
9136
const selects = document.querySelectorAll('select')
10137

@@ -78,6 +205,8 @@ document.addEventListener('DOMContentLoaded', () => {
78205
initializeSelects()
79206
})
80207

208+
Alpine.start()
209+
81210
if (typeof window.htmx !== 'undefined') {
82211
const reinitializeDynamicUi = () => {
83212
initializeFeaturedPackagesSlider(document)

src/Controller/PackagesController.php

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
namespace App\Controller;
55

66
use Cake\Core\Configure;
7+
use Cake\Http\Response;
78
use Cake\ORM\Query\SelectQuery;
89

910
/**
@@ -20,7 +21,7 @@ public function initialize(): void
2021
{
2122
parent::initialize();
2223

23-
$this->Authentication->allowUnauthenticated(['index']);
24+
$this->Authentication->allowUnauthenticated(['index', 'autocomplete']);
2425
}
2526

2627
/**
@@ -98,6 +99,53 @@ public function index()
9899
$this->set(compact('featuredPackages', 'packages', 'cakephpTags', 'phpTags'));
99100
}
100101

102+
/**
103+
* Autocomplete endpoint for package search.
104+
*
105+
* @return \Cake\Http\Response
106+
*/
107+
public function autocomplete(): Response
108+
{
109+
$q = trim((string)$this->request->getQuery('q'));
110+
if (mb_strlen($q) < 2) {
111+
return $this->response
112+
->withType('application/json')
113+
->withStringBody(json_encode([], JSON_THROW_ON_ERROR));
114+
}
115+
116+
$packages = $this->Packages
117+
->find('autocomplete', search: $q)
118+
->all();
119+
120+
$results = [];
121+
foreach ($packages as $package) {
122+
$cakeVersions = [];
123+
foreach ($package->cake_php_tag_groups as $major => $tags) {
124+
$cakeVersions[] = $major . '.x';
125+
}
126+
127+
$phpVersions = [];
128+
foreach ($package->php_tag_groups as $major => $tags) {
129+
$phpVersions[] = $major . '.x';
130+
}
131+
132+
$results[] = [
133+
'package' => $package->package,
134+
'description' => $package->description,
135+
'repo_url' => $package->repo_url,
136+
'downloads' => $package->downloads,
137+
'stars' => $package->stars,
138+
'latest_version' => $package->latest_stable_version,
139+
'cakephp_versions' => $cakeVersions,
140+
'php_versions' => $phpVersions,
141+
];
142+
}
143+
144+
return $this->response
145+
->withType('application/json')
146+
->withStringBody(json_encode($results, JSON_THROW_ON_ERROR));
147+
}
148+
101149
/**
102150
* @param mixed $value
103151
* @return bool

src/Model/Table/PackagesTable.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
namespace App\Model\Table;
55

66
use App\Model\Filter\PackagesCollection;
7+
use Cake\ORM\Query\SelectQuery;
78
use Cake\ORM\Table;
89
use Cake\Validation\Validator;
910

@@ -93,4 +94,33 @@ public function validationDefault(Validator $validator): Validator
9394

9495
return $validator;
9596
}
97+
98+
/**
99+
* Finder for autocomplete search results.
100+
*
101+
* @param \Cake\ORM\Query\SelectQuery $query Query instance.
102+
* @param string $search Search term.
103+
* @param int $maxResults Maximum number of results.
104+
* @return \Cake\ORM\Query\SelectQuery
105+
*/
106+
public function findAutocomplete(SelectQuery $query, string $search, int $maxResults = 8): SelectQuery
107+
{
108+
$escapedSearch = str_replace(['%', '_'], ['\%', '\_'], $search);
109+
110+
return $query
111+
->find('search', search: ['search' => $search])
112+
->contain(['Tags' => function (SelectQuery $q) {
113+
return $q->orderByDesc('Tags.label');
114+
}])
115+
->selectAlso([
116+
'name_match' => $query->expr()
117+
->case()
118+
->when(['Packages.package LIKE' => '%' . $escapedSearch . '%'])
119+
->then(1, 'integer')
120+
->else(0, 'integer'),
121+
])
122+
->orderByDesc('name_match')
123+
->orderByDesc('Packages.downloads')
124+
->limit($maxResults);
125+
}
96126
}

src/View/AppView.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,6 @@ public function initialize(): void
4141
$this->loadHelper('Form', ['templates' => 'form-templates']);
4242
$this->loadHelper('Html', ['templates' => 'html-templates']);
4343
$this->loadHelper('Authentication.Identity');
44+
$this->loadHelper('User');
4445
}
4546
}

0 commit comments

Comments
 (0)