From 4e213dab6845ee91fdfa5f26f92255e816b848a7 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Tue, 7 Apr 2026 16:54:21 +0200 Subject: [PATCH 1/2] ui: address autocomplete and navbar --- config/Migrations/schema-dump-default.lock | Bin 24546 -> 15833 bytes config/routes.php | 2 + package-lock.json | 27 +++- package.json | 1 + resources/css/style.css | 4 + resources/js/app.js | 122 +++++++++++++++ src/Controller/PackagesController.php | 50 +++++- src/Model/Table/PackagesTable.php | 28 ++++ src/View/AppView.php | 1 + src/View/Helper/UserHelper.php | 93 +++++++++++ templates/element/navbar.php | 114 ++++++++++++++ templates/element/search.php | 148 ++++++++++++++++++ templates/layout/default.php | 142 +---------------- tests/Fixture/PackagesFixture.php | 20 +++ .../Controller/PackagesControllerTest.php | 94 +++++++++++ .../Model/Table/PackagesTableTest.php | 115 ++++++++++++++ tests/TestCase/View/Helper/UserHelperTest.php | 127 +++++++++++++++ 17 files changed, 945 insertions(+), 143 deletions(-) create mode 100644 src/View/Helper/UserHelper.php create mode 100644 templates/element/navbar.php create mode 100644 templates/element/search.php create mode 100644 tests/TestCase/View/Helper/UserHelperTest.php diff --git a/config/Migrations/schema-dump-default.lock b/config/Migrations/schema-dump-default.lock index 7a7fafbb37affd04c0ebbf6daad877ea86c7545e..94a41e91163aa4790da9956656b259701f56850d 100644 GIT binary patch delta 22 dcmaE~pYdijbE1{WMrL>3$q})#n+x=d3;<_02r2*o delta 1212 zcmcIkO>fgc5Um@jp(RO8(|kY$5vCLgN{JJaHtP@xE)|E+{sXg#H|Z*_6WLBb)E4y) z5{l*q2jIXFp~ycV^uQfXh#PR=ScyM?*mWH8g^)OK^SpigX6DVTKOcPS2tJH5ZQpt@ zmO=*>${|Us?;3g?YnG&Jpi~wlRvb59_8zOi6jb&gG2kW(x?5kj@4d@f2;$F!3*&E{PP*sVgr|JxJqC)ufXPHh^G(sVM#fHbMs+% znoq#PJ9}Z)C$FturdU@SX1zl7mUwq=3?y*892aEgH1te3!{nM4F;#fEtoXeEK8P|j zl1uQ#|MM*KiD~UnW+t@6++ggq)4C8!Aqkhu#IjVoUL(+&L83g28PHBgrb^Vn=04!d zYfSeI9A`H|d@fAbuxW*M59+F zGINBM?WRf8rm6E1#;_haC^KEMjj68gFu6;yf;fb*zTws4yz@BoPVgLeoq>4l(Lh{; zVjwyxXJHd9RC^Pnn%4z2Y*OW`?rvOfSUhBnShh)xDi217;t{%IaS6lIc#Bp*M@r9L zXrWv8#$D|C=Dxz;wvDImTLzs#WNy?0qQNP`5t;uL2HP9^Cw!Hnes^7|1}945>LB^Q Lyhf)Oe3JYH39gCL diff --git a/config/routes.php b/config/routes.php index 19b5c8ca..14ad6804 100644 --- a/config/routes.php +++ b/config/routes.php @@ -50,12 +50,14 @@ $routes->setRouteClass(DashedRoute::class); $routes->scope('/', function (RouteBuilder $builder): void { + $builder->setExtensions(['json']); /* * Here, we are connecting '/' (base path) to a controller called 'Pages', * its action called 'display', and we pass a param to select the view file * to use (in this case, templates/Pages/home.php)... */ $builder->connect('/', ['controller' => 'Packages', 'action' => 'index']); + $builder->connect('/autocomplete', ['controller' => 'Packages', 'action' => 'autocomplete']); $builder->connect('/requirements', ['controller' => 'Pages', 'action' => 'display', 'requirements']); /* diff --git a/package-lock.json b/package-lock.json index 15e72810..cefb3c54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,11 @@ { - "name": "html", + "name": "plugins.cakephp.org", "lockfileVersion": 3, "requires": true, "packages": { "": { "dependencies": { + "alpinejs": "^3.15.11", "htmx.org": "^2.0.8", "slim-select": "^3.4.3", "swiper": "^12.1.3" @@ -674,6 +675,30 @@ "tslib": "^2.4.0" } }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "license": "MIT" + }, + "node_modules/alpinejs": { + "version": "3.15.11", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.11.tgz", + "integrity": "sha512-m26gkTg/MId8O+F4jHKK3vB3SjbFxxk/JHP+qzmw1H6aQrZuPAg4CUoAefnASzzp/eNroBjrRQe7950bNeaBJw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, "node_modules/daisyui": { "version": "5.5.19", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", diff --git a/package.json b/package.json index a3903390..e697e290 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build": "vite build" }, "dependencies": { + "alpinejs": "^3.15.11", "htmx.org": "^2.0.8", "slim-select": "^3.4.3", "swiper": "^12.1.3" diff --git a/resources/css/style.css b/resources/css/style.css index a59c4632..7a47c007 100644 --- a/resources/css/style.css +++ b/resources/css/style.css @@ -51,6 +51,10 @@ --color-cake-blue: #2F85AE; } +[x-cloak] { + display: none !important; +} + /* Styling for default app homepage */ .bullet:before { diff --git a/resources/js/app.js b/resources/js/app.js index 6929cbeb..56a06010 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -2,9 +2,129 @@ import Swiper from 'swiper' import { Autoplay, Navigation, Pagination } from 'swiper/modules' import htmx from 'htmx.org' import SlimSelect from 'slim-select' +import Alpine from 'alpinejs' window.htmx = htmx +Alpine.data('packageSearch', () => ({ + query: '', + results: [], + open: false, + loading: false, + selectedIndex: -1, + abortController: null, + debounceTimer: null, + + init() { + // Sync initial value from the input + this.query = this.$refs.input?.value || '' + + this.$watch('query', (value) => { + this.debouncedFetch(value) + }) + }, + + debouncedFetch(value) { + clearTimeout(this.debounceTimer) + + if (this.abortController) { + this.abortController.abort() + this.abortController = null + } + + if (value.trim().length < 2) { + this.results = [] + this.open = false + this.loading = false + return + } + + this.loading = true + + this.debounceTimer = setTimeout(() => { + this.fetchResults(value.trim()) + }, 250) + }, + + async fetchResults(q) { + if (this.abortController) { + this.abortController.abort() + } + + this.abortController = new AbortController() + + try { + const response = await fetch(`/autocomplete?q=${encodeURIComponent(q)}`, { + signal: this.abortController.signal, + headers: { 'Accept': 'application/json' }, + }) + + const data = await response.json() + this.results = data + this.open = data.length > 0 + this.selectedIndex = -1 + } catch (e) { + if (e.name !== 'AbortError') { + this.results = [] + this.open = false + } + } finally { + this.loading = false + } + }, + + close() { + this.open = false + this.selectedIndex = -1 + }, + + onKeydown(e) { + if (!this.open) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1) + this.scrollToSelected() + break + case 'ArrowUp': + e.preventDefault() + this.selectedIndex = Math.max(this.selectedIndex - 1, -1) + this.scrollToSelected() + break + case 'Enter': + if (this.selectedIndex >= 0) { + e.preventDefault() + this.selectResult(this.results[this.selectedIndex]) + } + break + case 'Escape': + this.close() + break + } + }, + + scrollToSelected() { + this.$nextTick(() => { + const el = this.$refs.listbox?.querySelector('[aria-selected="true"]') + el?.scrollIntoView({ block: 'nearest' }) + }) + }, + + selectResult(result) { + window.open(result.repo_url, '_blank', 'noopener,noreferrer') + this.close() + }, + + + + formatNumber(num) { + if (num >= 1000000) return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M' + if (num >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k' + return String(num) + }, +})) + const initializeSelects = (root = document) => { const selects = document.querySelectorAll('select') @@ -78,6 +198,8 @@ document.addEventListener('DOMContentLoaded', () => { initializeSelects() }) +Alpine.start() + if (typeof window.htmx !== 'undefined') { const reinitializeDynamicUi = () => { initializeFeaturedPackagesSlider(document) diff --git a/src/Controller/PackagesController.php b/src/Controller/PackagesController.php index 03924bc6..885de749 100644 --- a/src/Controller/PackagesController.php +++ b/src/Controller/PackagesController.php @@ -4,6 +4,7 @@ namespace App\Controller; use Cake\Core\Configure; +use Cake\Http\Response; use Cake\ORM\Query\SelectQuery; /** @@ -20,7 +21,7 @@ public function initialize(): void { parent::initialize(); - $this->Authentication->allowUnauthenticated(['index']); + $this->Authentication->allowUnauthenticated(['index', 'autocomplete']); } /** @@ -98,6 +99,53 @@ public function index() $this->set(compact('featuredPackages', 'packages', 'cakephpTags', 'phpTags')); } + /** + * Autocomplete endpoint for package search. + * + * @return \Cake\Http\Response + */ + public function autocomplete(): Response + { + $q = trim((string)$this->request->getQuery('q')); + if (mb_strlen($q) < 2) { + return $this->response + ->withType('application/json') + ->withStringBody(json_encode([])); + } + + $packages = $this->Packages + ->find('autocomplete', search: $q) + ->all(); + + $results = []; + foreach ($packages as $package) { + $cakeVersions = []; + foreach ($package->cake_php_tag_groups as $major => $tags) { + $cakeVersions[] = $major . '.x'; + } + + $phpVersions = []; + foreach ($package->php_tag_groups as $major => $tags) { + $phpVersions[] = $major . '.x'; + } + + $results[] = [ + 'package' => $package->package, + 'description' => $package->description, + 'repo_url' => $package->repo_url, + 'downloads' => $package->downloads, + 'stars' => $package->stars, + 'latest_version' => $package->latest_stable_version, + 'cakephp_versions' => $cakeVersions, + 'php_versions' => $phpVersions, + ]; + } + + return $this->response + ->withType('application/json') + ->withStringBody(json_encode($results)); + } + /** * @param mixed $value * @return bool diff --git a/src/Model/Table/PackagesTable.php b/src/Model/Table/PackagesTable.php index 394100e2..71d243f5 100644 --- a/src/Model/Table/PackagesTable.php +++ b/src/Model/Table/PackagesTable.php @@ -4,6 +4,7 @@ namespace App\Model\Table; use App\Model\Filter\PackagesCollection; +use Cake\ORM\Query\SelectQuery; use Cake\ORM\Table; use Cake\Validation\Validator; @@ -93,4 +94,31 @@ public function validationDefault(Validator $validator): Validator return $validator; } + + /** + * Finder for autocomplete search results. + * + * @param \Cake\ORM\Query\SelectQuery $query Query instance. + * @param string $search Search term. + * @param int $maxResults Maximum number of results. + * @return \Cake\ORM\Query\SelectQuery + */ + public function findAutocomplete(SelectQuery $query, string $search, int $maxResults = 8): SelectQuery + { + return $query + ->find('search', search: ['search' => $search]) + ->contain(['Tags' => function (SelectQuery $q) { + return $q->orderByDesc('Tags.label'); + }]) + ->selectAlso([ + 'name_match' => $query->expr() + ->case() + ->when(['Packages.package LIKE' => '%' . $search . '%']) + ->then(1, 'integer') + ->else(0, 'integer'), + ]) + ->orderByDesc('name_match') + ->orderByDesc('Packages.downloads') + ->limit($maxResults); + } } diff --git a/src/View/AppView.php b/src/View/AppView.php index 796ad382..f9893d66 100644 --- a/src/View/AppView.php +++ b/src/View/AppView.php @@ -41,5 +41,6 @@ public function initialize(): void $this->loadHelper('Form', ['templates' => 'form-templates']); $this->loadHelper('Html', ['templates' => 'html-templates']); $this->loadHelper('Authentication.Identity'); + $this->loadHelper('User'); } } diff --git a/src/View/Helper/UserHelper.php b/src/View/Helper/UserHelper.php new file mode 100644 index 00000000..3071281e --- /dev/null +++ b/src/View/Helper/UserHelper.php @@ -0,0 +1,93 @@ +Identity->isLoggedIn()) { + return null; + } + + return (string)$this->Identity->get('username') ?: null; + } + + /** + * Get the display name of the logged-in user. + * + * Falls back to username if first/last name are not set. + * + * @return string|null + */ + public function displayName(): ?string + { + if (!$this->Identity->isLoggedIn()) { + return null; + } + + $firstName = (string)$this->Identity->get('first_name'); + $lastName = (string)$this->Identity->get('last_name'); + $fullName = trim($firstName . ' ' . $lastName); + + return $fullName ?: $this->username(); + } + + /** + * Get the GitHub avatar URL for the logged-in user. + * + * @param int $size Image size in pixels. + * @return string|null + */ + public function avatarUrl(int $size = 80): ?string + { + $username = $this->username(); + if ($username === null) { + return null; + } + + return 'https://github.com/' . urlencode($username) . '.png?size=' . $size; + } + + /** + * Render an avatar `` tag for the logged-in user. + * + * @param int $size Image size in pixels. + * @param array $attrs Additional HTML attributes for the img tag. + * @return string|null + */ + public function avatar(int $size = 80, array $attrs = []): ?string + { + $url = $this->avatarUrl($size); + if ($url === null) { + return null; + } + + $attrs += [ + 'alt' => $this->username(), + 'loading' => 'lazy', + 'referrerpolicy' => 'no-referrer', + ]; + + return $this->Html->image($url, $attrs); + } +} diff --git a/templates/element/navbar.php b/templates/element/navbar.php new file mode 100644 index 00000000..deb2eb0b --- /dev/null +++ b/templates/element/navbar.php @@ -0,0 +1,114 @@ + +
+ +
diff --git a/templates/element/search.php b/templates/element/search.php new file mode 100644 index 00000000..41839a6e --- /dev/null +++ b/templates/element/search.php @@ -0,0 +1,148 @@ +getRequest(); +$isPackagesIndex = $request->getParam('controller') === 'Packages' && $request->getParam('action') === 'index'; +$searchValue = (string)$request->getQuery('search', ''); +$cakephpSlugs = (array)$request->getQuery('cakephp_slugs', []); +$phpSlugs = (array)$request->getQuery('php_slugs', []); + +$searchFormOptions = [ + 'type' => 'get', + 'url' => ['controller' => 'Packages', 'action' => 'index'], + 'class' => 'w-full max-w-xl relative', + 'valueSources' => 'query', +]; +if ($isPackagesIndex) { + $searchFormOptions += [ + 'hx-get' => $this->Url->build(['controller' => 'Packages', 'action' => 'index']), + 'hx-target' => '#packages-index-content', + 'hx-select' => '#packages-index-content', + 'hx-swap' => 'outerHTML', + 'hx-push-url' => 'true', + ]; +} +echo $this->Form->create(null, $searchFormOptions); + +foreach ($cakephpSlugs as $slug) { + echo $this->Form->hidden('cakephp_slugs[]', ['value' => $slug]); +} +foreach ($phpSlugs as $slug) { + echo $this->Form->hidden('php_slugs[]', ['value' => $slug]); +} +?> +
+ + + +
+
    + +
+ + +
+ + +
+
+
+Form->end() ?> diff --git a/templates/layout/default.php b/templates/layout/default.php index 83add148..336f976b 100644 --- a/templates/layout/default.php +++ b/templates/layout/default.php @@ -17,10 +17,6 @@ $cakeDescription = 'CakePHP Plugins'; $request = $this->getRequest(); $canonicalUrl = $this->Url->build($request->getPath() ?: '/', ['fullBase' => true]); -$isPackagesIndex = $request->getParam('controller') === 'Packages' && $request->getParam('action') === 'index'; -$searchValue = (string)$request->getQuery('search', ''); -$cakephpSlugs = (array)$request->getQuery('cakephp_slugs', []); -$phpSlugs = (array)$request->getQuery('php_slugs', []); ?> @@ -43,143 +39,7 @@ fetch('script') ?> -
- -
+ element('navbar') ?>
fetch('above_content')) : ?>
diff --git a/tests/Fixture/PackagesFixture.php b/tests/Fixture/PackagesFixture.php index f720d2d3..dda787d9 100644 --- a/tests/Fixture/PackagesFixture.php +++ b/tests/Fixture/PackagesFixture.php @@ -28,6 +28,26 @@ public function init(): void 'latest_stable_version' => '5.0.0', 'latest_stable_release_date' => '2025-01-15', ], + [ + 'id' => 25, + 'package' => 'cakedc/users', + 'description' => 'Users plugin for CakePHP.', + 'repo_url' => 'https://github.com/cakedc/users', + 'downloads' => 9000, + 'stars' => 500, + 'latest_stable_version' => '12.0.0', + 'latest_stable_release_date' => '2025-03-01', + ], + [ + 'id' => 26, + 'package' => 'dereuromark/cakephp-tools', + 'description' => 'A CakePHP tools plugin containing lots of useful helpers.', + 'repo_url' => 'https://github.com/dereuromark/cakephp-tools', + 'downloads' => 20000, + 'stars' => 300, + 'latest_stable_version' => '3.0.0', + 'latest_stable_release_date' => '2025-04-01', + ], ]; for ($i = 2; $i <= 24; $i++) { diff --git a/tests/TestCase/Controller/PackagesControllerTest.php b/tests/TestCase/Controller/PackagesControllerTest.php index 49c7fa31..d2eaf3ac 100644 --- a/tests/TestCase/Controller/PackagesControllerTest.php +++ b/tests/TestCase/Controller/PackagesControllerTest.php @@ -23,6 +23,8 @@ class PackagesControllerTest extends TestCase */ protected array $fixtures = [ 'app.Packages', + 'plugin.Tags.Tags', + 'plugin.Tags.Tagged', ]; /** @@ -70,4 +72,96 @@ public function testIndexHidesFeaturedSliderWhenSearching(): void $this->assertResponseNotContains('data-featured-packages-slider'); $this->assertResponseContains('vendor/package-02'); } + + /** + * @return void + */ + public function testAutocompleteReturnsJsonResults(): void + { + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + $this->get('/autocomplete?q=asset'); + + $this->assertResponseOk(); + $this->assertContentType('application/json'); + + $body = (string)$this->_response->getBody(); + $results = json_decode($body, true); + + $this->assertNotEmpty($results); + $this->assertSame('markstory/asset_compress', $results[0]['package']); + $this->assertArrayHasKey('description', $results[0]); + $this->assertArrayHasKey('repo_url', $results[0]); + $this->assertArrayHasKey('downloads', $results[0]); + $this->assertArrayHasKey('stars', $results[0]); + $this->assertArrayHasKey('latest_version', $results[0]); + $this->assertArrayHasKey('cakephp_versions', $results[0]); + $this->assertArrayHasKey('php_versions', $results[0]); + } + + /** + * @return void + */ + public function testAutocompleteReturnsEmptyForShortQuery(): void + { + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + $this->get('/autocomplete?q=a'); + + $this->assertResponseOk(); + $this->assertContentType('application/json'); + + $body = (string)$this->_response->getBody(); + $results = json_decode($body, true); + + $this->assertEmpty($results); + } + + /** + * @return void + */ + public function testAutocompleteReturnsEmptyForMissingQuery(): void + { + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + $this->get('/autocomplete'); + + $this->assertResponseOk(); + $this->assertContentType('application/json'); + + $body = (string)$this->_response->getBody(); + $results = json_decode($body, true); + + $this->assertEmpty($results); + } + + /** + * @return void + */ + public function testAutocompleteReturnsEmptyForNoMatch(): void + { + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + $this->get('/autocomplete?q=zzzznonexistent'); + + $this->assertResponseOk(); + $this->assertContentType('application/json'); + + $body = (string)$this->_response->getBody(); + $results = json_decode($body, true); + + $this->assertEmpty($results); + } + + /** + * @return void + */ + public function testAutocompleteLimitsResults(): void + { + $this->configRequest(['headers' => ['Accept' => 'application/json']]); + $this->get('/autocomplete?q=package'); + + $this->assertResponseOk(); + + $body = (string)$this->_response->getBody(); + $results = json_decode($body, true); + + $this->assertLessThanOrEqual(8, count($results)); + } } diff --git a/tests/TestCase/Model/Table/PackagesTableTest.php b/tests/TestCase/Model/Table/PackagesTableTest.php index eb3d8d1d..90630896 100644 --- a/tests/TestCase/Model/Table/PackagesTableTest.php +++ b/tests/TestCase/Model/Table/PackagesTableTest.php @@ -25,6 +25,8 @@ class PackagesTableTest extends TestCase */ protected array $fixtures = [ 'app.Packages', + 'plugin.Tags.Tags', + 'plugin.Tags.Tagged', ]; /** @@ -61,4 +63,117 @@ public function testValidationDefault(): void { $this->markTestIncomplete('Not implemented yet.'); } + + /** + * Test findAutocomplete returns results matching the package name. + * + * @return void + * @link \App\Model\Table\PackagesTable::findAutocomplete() + */ + public function testFindAutocompleteMatchesPackageName(): void + { + $results = $this->Packages->find('autocomplete', search: 'asset_compress')->toArray(); + + $this->assertNotEmpty($results); + $this->assertSame('markstory/asset_compress', $results[0]->package); + } + + /** + * Test findAutocomplete respects the limit option. + * + * @return void + */ + public function testFindAutocompleteLimit(): void + { + $results = $this->Packages->find('autocomplete', search: 'package', maxResults: 3)->toArray(); + + $this->assertCount(3, $results); + } + + /** + * Test findAutocomplete default limit is 8. + * + * @return void + */ + public function testFindAutocompleteDefaultLimit(): void + { + $results = $this->Packages->find('autocomplete', search: 'package')->toArray(); + + $this->assertLessThanOrEqual(8, count($results)); + } + + /** + * Test findAutocomplete returns empty results for non-matching search. + * + * @return void + */ + public function testFindAutocompleteNoResults(): void + { + $results = $this->Packages->find('autocomplete', search: 'zzzznonexistent')->toArray(); + + $this->assertEmpty($results); + } + + /** + * Test findAutocomplete prioritizes name matches over description-only matches. + * + * "users" appears in cakedc/users package name and also in the description + * of dereuromark/cakephp-tools ("useful helpers"). The package with "users" + * in its name should rank first even if the other has more downloads. + * + * @return void + */ + public function testFindAutocompletePrioritizesNameMatch(): void + { + $results = $this->Packages->find('autocomplete', search: 'users')->toArray(); + + $this->assertNotEmpty($results); + $this->assertSame('cakedc/users', $results[0]->package); + } + + /** + * Test findAutocomplete orders by downloads within the same match type. + * + * @return void + */ + public function testFindAutocompleteOrdersByDownloads(): void + { + $results = $this->Packages->find('autocomplete', search: 'package')->toArray(); + + $count = count($results); + $this->assertGreaterThanOrEqual(2, $count); + for ($i = 1; $i < $count; $i++) { + $this->assertGreaterThanOrEqual( + $results[$i]->downloads, + $results[$i - 1]->downloads, + 'Results should be ordered by downloads descending', + ); + } + } + + /** + * Test findAutocomplete matches against description field. + * + * @return void + */ + public function testFindAutocompleteMatchesDescription(): void + { + $results = $this->Packages->find('autocomplete', search: 'slider')->toArray(); + + $this->assertNotEmpty($results); + $this->assertSame('markstory/asset_compress', $results[0]->package); + } + + /** + * Test findAutocomplete contains tags. + * + * @return void + */ + public function testFindAutocompleteContainsTags(): void + { + $results = $this->Packages->find('autocomplete', search: 'asset_compress')->toArray(); + + $this->assertNotEmpty($results); + $this->assertArrayHasKey('tags', $results[0]->toArray()); + } } diff --git a/tests/TestCase/View/Helper/UserHelperTest.php b/tests/TestCase/View/Helper/UserHelperTest.php new file mode 100644 index 00000000..1b8dbefd --- /dev/null +++ b/tests/TestCase/View/Helper/UserHelperTest.php @@ -0,0 +1,127 @@ +withAttribute('identity', $identity); + } + $view = new View($request); + + return new UserHelper($view); + } + + public function testUsernameLoggedIn(): void + { + $helper = $this->createHelper(['username' => 'markstory']); + $this->assertSame('markstory', $helper->username()); + } + + public function testUsernameNotLoggedIn(): void + { + $helper = $this->createHelper(); + $this->assertNull($helper->username()); + } + + public function testDisplayNameFullName(): void + { + $helper = $this->createHelper([ + 'username' => 'markstory', + 'first_name' => 'Mark', + 'last_name' => 'Story', + ]); + $this->assertSame('Mark Story', $helper->displayName()); + } + + public function testDisplayNameFirstNameOnly(): void + { + $helper = $this->createHelper([ + 'username' => 'markstory', + 'first_name' => 'Mark', + 'last_name' => '', + ]); + $this->assertSame('Mark', $helper->displayName()); + } + + public function testDisplayNameFallsBackToUsername(): void + { + $helper = $this->createHelper([ + 'username' => 'markstory', + 'first_name' => '', + 'last_name' => '', + ]); + $this->assertSame('markstory', $helper->displayName()); + } + + public function testDisplayNameNotLoggedIn(): void + { + $helper = $this->createHelper(); + $this->assertNull($helper->displayName()); + } + + public function testAvatarUrl(): void + { + $helper = $this->createHelper(['username' => 'markstory']); + $this->assertSame( + 'https://github.com/markstory.png?size=80', + $helper->avatarUrl(), + ); + } + + public function testAvatarUrlCustomSize(): void + { + $helper = $this->createHelper(['username' => 'markstory']); + $this->assertSame( + 'https://github.com/markstory.png?size=200', + $helper->avatarUrl(200), + ); + } + + public function testAvatarUrlNotLoggedIn(): void + { + $helper = $this->createHelper(); + $this->assertNull($helper->avatarUrl()); + } + + public function testAvatar(): void + { + $helper = $this->createHelper(['username' => 'markstory']); + $result = $helper->avatar(); + + $this->assertStringContainsString('assertStringContainsString('https://github.com/markstory.png?size=80', $result); + $this->assertStringContainsString('alt="markstory"', $result); + $this->assertStringContainsString('loading="lazy"', $result); + $this->assertStringContainsString('referrerpolicy="no-referrer"', $result); + } + + public function testAvatarCustomAttrs(): void + { + $helper = $this->createHelper(['username' => 'markstory']); + $result = $helper->avatar(80, ['class' => 'w-full', 'alt' => '']); + + $this->assertStringContainsString('class="w-full"', $result); + $this->assertStringContainsString('alt=""', $result); + } + + public function testAvatarNotLoggedIn(): void + { + $helper = $this->createHelper(); + $this->assertNull($helper->avatar()); + } +} From 5752509a76f46ef3cac72d7c6bc0bb06f034d6e8 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Tue, 7 Apr 2026 17:09:58 +0200 Subject: [PATCH 2/2] Address copilot reviews --- resources/js/app.js | 9 ++++++++- src/Controller/PackagesController.php | 4 ++-- src/Model/Table/PackagesTable.php | 4 +++- templates/element/navbar.php | 4 ++-- templates/element/search.php | 1 - tests/TestCase/View/Helper/UserHelperTest.php | 2 -- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/resources/js/app.js b/resources/js/app.js index 56a06010..6a6ba2e7 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -112,7 +112,14 @@ Alpine.data('packageSearch', () => ({ }, selectResult(result) { - window.open(result.repo_url, '_blank', 'noopener,noreferrer') + try { + const url = new URL(result.repo_url) + if (url.protocol === 'https:') { + window.open(url.toString(), '_blank', 'noopener,noreferrer') + } + } catch { + // Invalid URL, ignore + } this.close() }, diff --git a/src/Controller/PackagesController.php b/src/Controller/PackagesController.php index 885de749..d7962dd9 100644 --- a/src/Controller/PackagesController.php +++ b/src/Controller/PackagesController.php @@ -110,7 +110,7 @@ public function autocomplete(): Response if (mb_strlen($q) < 2) { return $this->response ->withType('application/json') - ->withStringBody(json_encode([])); + ->withStringBody(json_encode([], JSON_THROW_ON_ERROR)); } $packages = $this->Packages @@ -143,7 +143,7 @@ public function autocomplete(): Response return $this->response ->withType('application/json') - ->withStringBody(json_encode($results)); + ->withStringBody(json_encode($results, JSON_THROW_ON_ERROR)); } /** diff --git a/src/Model/Table/PackagesTable.php b/src/Model/Table/PackagesTable.php index 71d243f5..d3435b62 100644 --- a/src/Model/Table/PackagesTable.php +++ b/src/Model/Table/PackagesTable.php @@ -105,6 +105,8 @@ public function validationDefault(Validator $validator): Validator */ public function findAutocomplete(SelectQuery $query, string $search, int $maxResults = 8): SelectQuery { + $escapedSearch = str_replace(['%', '_'], ['\%', '\_'], $search); + return $query ->find('search', search: ['search' => $search]) ->contain(['Tags' => function (SelectQuery $q) { @@ -113,7 +115,7 @@ public function findAutocomplete(SelectQuery $query, string $search, int $maxRes ->selectAlso([ 'name_match' => $query->expr() ->case() - ->when(['Packages.package LIKE' => '%' . $search . '%']) + ->when(['Packages.package LIKE' => '%' . $escapedSearch . '%']) ->then(1, 'integer') ->else(0, 'integer'), ]) diff --git a/templates/element/navbar.php b/templates/element/navbar.php index deb2eb0b..c2815189 100644 --- a/templates/element/navbar.php +++ b/templates/element/navbar.php @@ -50,7 +50,7 @@
-
+
  • Html->link('Sign out', [ 'controller' => 'Users', @@ -92,7 +92,7 @@
  • Html->link('Docs', 'https://book.cakephp.org/', ['target' => '_blank', 'rel' => 'noopener']) ?>
  • Html->link('API', 'https://api.cakephp.org/', ['target' => '_blank', 'rel' => 'noopener']) ?>
  • Identity->isLoggedIn()) : ?> -
    +
  • Form->postLink( 'Sign in with GitHub', diff --git a/templates/element/search.php b/templates/element/search.php index 41839a6e..df764779 100644 --- a/templates/element/search.php +++ b/templates/element/search.php @@ -48,7 +48,6 @@ class="grow min-w-0" autocomplete="off" role="combobox" - aria-expanded="false" :aria-expanded="open" aria-haspopup="listbox" aria-controls="autocomplete-listbox" diff --git a/tests/TestCase/View/Helper/UserHelperTest.php b/tests/TestCase/View/Helper/UserHelperTest.php index 1b8dbefd..5e28a9ee 100644 --- a/tests/TestCase/View/Helper/UserHelperTest.php +++ b/tests/TestCase/View/Helper/UserHelperTest.php @@ -12,8 +12,6 @@ class UserHelperTest extends TestCase { - protected UserHelper $User; - protected function createHelper(?array $identityData = null): UserHelper { $request = new ServerRequest();