mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(search): add settings search to the command palette (#12111)
This commit is contained in:
@@ -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());
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user