feat(search): add settings search to the command palette (#12111)

This commit is contained in:
Andras Bacsai
2026-10-07 13:24:21 +02:00
committed by GitHub
parent 38861e1394
commit 522ed390ce
25 changed files with 883 additions and 281 deletions
@@ -0,0 +1,93 @@
<?php
use App\Livewire\GlobalSearch;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Cache::flush();
});
function globalSearchNavigationLinks(Team $team, string $role = 'owner'): array
{
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => $role]);
test()->actingAs($user);
session(['currentTeam' => $team]);
return collect(Livewire::test(GlobalSearch::class)->call('openSearchModal')->get('allSearchableItems'))
->where('type', 'navigation')
->pluck('link')
->all();
}
function adminOnlyNavigationLinks(): array
{
return [
route('registries.index'),
route('team.audit-log'),
route('team.danger-zone'),
route('security.cloud-tokens'),
route('security.integration-tokens'),
route('security.cloud-init-scripts'),
route('terminal'),
];
}
it('lists every instance settings page for the root team', function () {
$links = globalSearchNavigationLinks(Team::factory()->create(['id' => 0]));
expect($links)->toContain(
route('settings.index'),
route('settings.advanced'),
route('settings.updates'),
route('settings.backup'),
route('settings.email'),
route('settings.oauth'),
);
});
it('does not list instance settings pages for other teams', function () {
$links = globalSearchNavigationLinks(Team::factory()->create());
expect(collect($links)->filter(fn (string $link) => str_contains($link, '/settings')))->toBeEmpty();
});
it('lists team, security, notification and profile pages for a team owner', function () {
$links = globalSearchNavigationLinks(Team::factory()->create());
expect($links)->toContain(
route('analytics'),
route('shared-variables.server.index'),
route('team.member.index'),
route('security.api-tokens'),
route('notifications.discord'),
route('notifications.telegram'),
route('notifications.slack'),
route('notifications.pushover'),
route('notifications.webhook'),
route('profile.appearance'),
...adminOnlyNavigationLinks(),
);
});
it('hides admin-only pages from team members', function () {
$links = globalSearchNavigationLinks(Team::factory()->create(), 'member');
expect($links)->toContain(route('team.member.index'), route('security.api-tokens'))
->not->toContain(...adminOnlyNavigationLinks());
});
it('does not leak admin-only pages to a member through the team search cache', function () {
$team = Team::factory()->create();
expect(globalSearchNavigationLinks($team))->toContain(...adminOnlyNavigationLinks());
expect(globalSearchNavigationLinks($team, 'member'))->not->toContain(...adminOnlyNavigationLinks());
});
+52
View File
@@ -0,0 +1,52 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function serverSettingsSearchGroups(): array
{
return [
'Security' => [
[
'label' => 'Security',
'route' => 'server.security.patches',
'children' => [
['label' => 'Server Patching', 'route' => 'server.security.patches'],
['label' => 'Terminal Access', 'route' => 'server.security.terminal-access', 'navigate' => false],
['label' => 'Hidden', 'route' => 'server.security', 'visible' => false],
],
],
],
'General' => [
['label' => 'Proxy', 'route' => 'server.proxy'],
],
];
}
it('lists pages, visible child pages and in-page sections with their breadcrumbs', function () {
InstanceSettings::forceCreate(['id' => 0]);
$items = settingsSearchItems(
serverSettingsSearchGroups(),
['server_uuid' => 'abc'],
['server.proxy' => [['id' => 'proxy-config-section', 'label' => 'Configuration']]],
);
expect($items)->toBe([
['label' => 'Security', 'breadcrumb' => 'Security', 'search_text' => 'Security Security', 'href' => route('server.security.patches', ['server_uuid' => 'abc']), 'navigate' => true],
['label' => 'Server Patching', 'breadcrumb' => 'Security · Security', 'search_text' => 'Server Patching Security · Security', 'href' => route('server.security.patches', ['server_uuid' => 'abc']), 'navigate' => true],
['label' => 'Terminal Access', 'breadcrumb' => 'Security · Security', 'search_text' => 'Terminal Access Security · Security', 'href' => route('server.security.terminal-access', ['server_uuid' => 'abc']), 'navigate' => false],
['label' => 'Proxy', 'breadcrumb' => 'General', 'search_text' => 'Proxy General', 'href' => route('server.proxy', ['server_uuid' => 'abc']), 'navigate' => true],
['label' => 'Configuration', 'breadcrumb' => 'General · Proxy', 'search_text' => 'Configuration General · Proxy', 'href' => route('server.proxy', ['server_uuid' => 'abc']).'#proxy-config-section', 'navigate' => true],
]);
});
it('uses full page loads when SPA navigation is disabled', function () {
InstanceSettings::forceCreate(['id' => 0, 'is_wire_navigate_enabled' => false]);
$items = settingsSearchItems(serverSettingsSearchGroups(), ['server_uuid' => 'abc']);
expect(collect($items)->pluck('navigate')->unique()->all())->toBe([false]);
});
@@ -0,0 +1,167 @@
<?php
use App\Models\GithubApp;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->stack = seedBrowserResourceStack();
$this->application = createBrowserApplication($this->stack, [
'uuid' => 'app-palette-settings',
'name' => 'Palette App',
]);
});
it('shows the settings of the open resource in the command palette and switches scope with Tab', function () {
loginAndSkipBoarding();
$page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application));
$page->assertSee('Palette App')
->assertMissing('[aria-label="Filter settings"]')
->assertVisible('button:visible:has-text("Search page & global")');
$page->script("window.dispatchEvent(new Event('open-global-search'))");
$page->assertVisible('.command-palette-input')
->assertAttribute('.command-palette-scope-option:has-text("Global")', 'aria-pressed', 'true')
->assertAttribute('.command-palette-scope-option:has-text("This page")', 'aria-pressed', 'false')
->type('.command-palette-input', 'runtime')
->assertSeeIn('.command-palette-body', 'This page')
->assertSeeIn('.command-palette-body', 'Runtime Logs')
->assertSeeIn('.command-palette-body', 'Settings · General')
->keys('.command-palette-input', 'Tab')
->assertAttribute('.command-palette-scope-option:has-text("This page")', 'aria-pressed', 'true')
->assertAttribute('.command-palette-scope-option:has-text("Global")', 'aria-pressed', 'false')
->assertScript("document.activeElement === document.querySelector('.command-palette-input')")
->type('.command-palette-input', 'zzz-nothing')
->assertSeeIn('.command-palette-body', 'Nothing on this page')
->type('.command-palette-input', 'runtime logs')
->screenshot(filename: 'command-palette-this-page-scope')
->click('.command-palette-body a.search-result-item')
->assertPathEndsWith('/logs')
->screenshot(filename: 'command-palette-opened-runtime-logs');
});
it('does not show the scope switch on pages without a settings sidebar', function () {
loginAndSkipBoarding();
$page = visit('/dashboard');
$page->assertDontSee('page & global');
$page->script("window.dispatchEvent(new Event('open-global-search'))");
$page->assertVisible('.command-palette-input')
->assertMissing('.command-palette-scope')
->screenshot(filename: 'command-palette-without-scope');
});
it('focuses the search input when the palette is opened from the mobile menu', function () {
loginAndSkipBoarding();
$page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application))
->on()->mobile();
$page->assertSee('Palette App')
->click('button:visible:has-text("Open sidebar")')
->assertSee('Search page & global')
->screenshot(filename: 'command-palette-mobile-menu')
->click('button:visible:has-text("Search")')
->assertVisible('.command-palette-input')
->wait(0.3)
->assertScript("document.activeElement === document.querySelector('.command-palette-input')")
->assertVisible('.command-palette-scope')
->screenshot(filename: 'command-palette-mobile');
});
function commandPaletteSettingsPageUrl(string $page, array $stack): string
{
return match ($page) {
'instance settings' => '/settings/oauth',
'team' => '/team',
'keys and tokens' => '/security/private-key',
'notifications' => '/notifications/email',
'shared variables' => '/shared-variables',
'destination' => '/destination/'.$stack['destination']->uuid,
's3 storage' => '/storages/'.S3Storage::create([
'team_id' => 0, 'name' => 'Palette S3', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret',
'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com',
])->uuid,
'github app' => '/source/github/'.GithubApp::create([
'name' => 'palette-app', 'organization' => 'acme', 'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'app_id' => 1234,
'installation_id' => 5678, 'webhook_secret' => 'secret', 'private_key_id' => $stack['privateKey']->id,
'team_id' => 0, 'is_system_wide' => false,
])->uuid,
'database backup' => (function () use ($stack): string {
$database = createBrowserPostgresql($stack, ['uuid' => 'db-palette-backup']);
$backup = ScheduledDatabaseBackup::create([
'team_id' => 0, 'frequency' => '0 0 * * *', 'database_type' => $database->getMorphClass(),
'database_id' => $database->id,
]);
return "/project/{$stack['project']->uuid}/environment/{$stack['environment']->uuid}/database/{$database->uuid}/backups/{$backup->uuid}";
})(),
};
}
it('searches the sidebar pages of other settings pages', function (string $pageType, string $expectedLabel) {
$url = commandPaletteSettingsPageUrl($pageType, $this->stack);
loginAndSkipBoarding();
$page = visit($url);
$page->assertVisible('button:visible:has-text("Search page & global")');
$page->script("window.dispatchEvent(new Event('open-global-search'))");
$page->assertVisible('.command-palette-scope')
->keys('.command-palette-input', 'Tab')
->assertSeeIn('.command-palette-body', $expectedLabel)
->screenshot(filename: 'command-palette-page-'.str($pageType)->slug());
})->with([
['instance settings', 'Updates'],
['team', 'Members'],
['keys and tokens', 'API Tokens'],
['notifications', 'Telegram'],
['shared variables', 'Environments'],
['destination', 'Danger Zone'],
['s3 storage', 'Resources'],
['github app', 'Permissions'],
['database backup', 'Retention'],
]);
it('searches the sections of a page without a settings sidebar and scrolls to them', function () {
loginAndSkipBoarding();
$page = visit('/profile');
$page->assertVisible('button:visible:has-text("Search page & global")');
$page->script("window.dispatchEvent(new Event('open-global-search'))");
$page->assertVisible('.command-palette-scope')
->keys('.command-palette-input', 'Tab')
->type('.command-palette-input', 'two-factor')
->assertSeeIn('.command-palette-body', 'Two-factor authentication')
->click('.command-palette-body a.search-result-item')
->assertMissing('.command-palette-input')
->wait(1)
->assertScript(<<<'JS'
() => {
const heading = [...document.querySelectorAll('.application-settings-section h2')]
.find(el => el.textContent.includes('Two-factor authentication'));
const top = heading.getBoundingClientRect().top;
return top >= 0 && top < window.innerHeight;
}
JS)
->screenshot(filename: 'command-palette-profile-section');
});
it('lists a section once when the sidebar already links to it', function () {
loginAndSkipBoarding();
$page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application));
$page->assertSee('Palette App');
expect($page->script("window.currentPageSearchItems().filter(item => item.label === 'Container labels').length"))->toBe(1);
});