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
+307 -137
View File
@@ -3,7 +3,10 @@
namespace App\Livewire;
use App\Models\Application;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\Environment;
use App\Models\IntegrationToken;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
@@ -16,6 +19,7 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\StandaloneSqlite;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
@@ -252,7 +256,7 @@ class GlobalSearch extends Component
// Try to get from Redis cache first
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
$resources = Cache::remember($cacheKey, 300, function () {
$items = collect();
$team = auth()->user()->currentTeam();
@@ -617,143 +621,8 @@ class GlobalSearch extends Component
];
});
// Add navigation routes
$navigation = collect([
[
'name' => 'Dashboard',
'type' => 'navigation',
'description' => 'Go to main dashboard',
'link' => route('dashboard'),
'search_text' => 'dashboard home main overview',
],
[
'name' => 'Servers',
'type' => 'navigation',
'description' => 'View all servers',
'link' => route('server.index'),
'search_text' => 'servers all list view',
],
[
'name' => 'Projects',
'type' => 'navigation',
'description' => 'View all projects',
'link' => route('project.index'),
'search_text' => 'projects all list view',
],
[
'name' => 'Destinations',
'type' => 'navigation',
'description' => 'View all destinations',
'link' => route('destination.index'),
'search_text' => 'destinations docker networks',
],
[
'name' => 'Security',
'type' => 'navigation',
'description' => 'Manage private keys and API tokens',
'link' => route('security.private-key.index'),
'search_text' => 'security private keys ssh api tokens cloud-init scripts',
],
[
'name' => 'Cloud-Init Scripts',
'type' => 'navigation',
'description' => 'Manage reusable cloud-init scripts',
'link' => route('security.cloud-init-scripts'),
'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup',
],
[
'name' => 'Sources',
'type' => 'navigation',
'description' => 'Manage GitHub apps and Git sources',
'link' => route('source.all'),
'search_text' => 'sources github apps git repositories',
],
[
'name' => 'Storages',
'type' => 'navigation',
'description' => 'Manage S3 storage for backups',
'link' => route('storage.index'),
'search_text' => 'storages s3 backups',
],
[
'name' => 'Shared Variables',
'type' => 'navigation',
'description' => 'View all shared variables',
'link' => route('shared-variables.index'),
'search_text' => 'shared variables environment all',
],
[
'name' => 'Team Shared Variables',
'type' => 'navigation',
'description' => 'Manage team-wide shared variables',
'link' => route('shared-variables.team.index'),
'search_text' => 'shared variables team environment',
],
[
'name' => 'Project Shared Variables',
'type' => 'navigation',
'description' => 'Manage project shared variables',
'link' => route('shared-variables.project.index'),
'search_text' => 'shared variables project environment',
],
[
'name' => 'Environment Shared Variables',
'type' => 'navigation',
'description' => 'Manage environment shared variables',
'link' => route('shared-variables.environment.index'),
'search_text' => 'shared variables environment',
],
[
'name' => 'Tags',
'type' => 'navigation',
'description' => 'View resources by tags',
'link' => route('tags.show'),
'search_text' => 'tags labels organize',
],
[
'name' => 'Terminal',
'type' => 'navigation',
'description' => 'Access server terminal',
'link' => route('terminal'),
'search_text' => 'terminal ssh console shell command line',
],
[
'name' => 'Profile',
'type' => 'navigation',
'description' => 'Manage your profile and preferences',
'link' => route('profile'),
'search_text' => 'profile account user settings preferences',
],
[
'name' => 'Team',
'type' => 'navigation',
'description' => 'Manage team members and settings',
'link' => route('team.index'),
'search_text' => 'team settings members users invitations',
],
[
'name' => 'Notifications',
'type' => 'navigation',
'description' => 'Configure email, Discord, Telegram notifications',
'link' => route('notifications.email'),
'search_text' => 'notifications alerts email discord telegram slack pushover',
],
]);
// Add instance settings only for self-hosted and root team
if (! isCloud() && $team->id === 0) {
$navigation->push([
'name' => 'Settings',
'type' => 'navigation',
'description' => 'Instance settings and configuration',
'link' => route('settings.index'),
'search_text' => 'settings configuration instance',
]);
}
// Merge all collections
$items = $items->merge($navigation)
->merge($applications)
$items = $items->merge($applications)
->merge($services)
->merge($databases)
->merge($servers)
@@ -762,6 +631,307 @@ class GlobalSearch extends Component
return $items->toArray();
});
// Navigation depends on the user's permissions, so it is not part of the team-wide cache
$this->allSearchableItems = $this->navigationItems()->merge($resources)->values()->all();
}
private function navigationItems(): Collection
{
$user = auth()->user();
$team = $user->currentTeam();
// Add navigation routes
$navigation = collect([
[
'name' => 'Dashboard',
'type' => 'navigation',
'description' => 'Go to main dashboard',
'link' => route('dashboard'),
'search_text' => 'dashboard home main overview',
],
[
'name' => 'Servers',
'type' => 'navigation',
'description' => 'View all servers',
'link' => route('server.index'),
'search_text' => 'servers all list view',
],
[
'name' => 'Projects',
'type' => 'navigation',
'description' => 'View all projects',
'link' => route('project.index'),
'search_text' => 'projects all list view',
],
[
'name' => 'Destinations',
'type' => 'navigation',
'description' => 'View all destinations',
'link' => route('destination.index'),
'search_text' => 'destinations docker networks',
],
[
'name' => 'Security',
'type' => 'navigation',
'description' => 'Manage private keys and API tokens',
'link' => route('security.private-key.index'),
'search_text' => 'security private keys ssh api tokens cloud-init scripts',
],
$user->can('viewAny', CloudInitScript::class) ? [
'name' => 'Cloud-Init Scripts',
'type' => 'navigation',
'description' => 'Manage reusable cloud-init scripts',
'link' => route('security.cloud-init-scripts'),
'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup',
] : null,
[
'name' => 'Sources',
'type' => 'navigation',
'description' => 'Manage GitHub apps and Git sources',
'link' => route('source.all'),
'search_text' => 'sources github apps git repositories',
],
[
'name' => 'Storages',
'type' => 'navigation',
'description' => 'Manage S3 storage for backups',
'link' => route('storage.index'),
'search_text' => 'storages s3 backups',
],
[
'name' => 'Shared Variables',
'type' => 'navigation',
'description' => 'View all shared variables',
'link' => route('shared-variables.index'),
'search_text' => 'shared variables environment all',
],
[
'name' => 'Team Shared Variables',
'type' => 'navigation',
'description' => 'Manage team-wide shared variables',
'link' => route('shared-variables.team.index'),
'search_text' => 'shared variables team environment',
],
[
'name' => 'Project Shared Variables',
'type' => 'navigation',
'description' => 'Manage project shared variables',
'link' => route('shared-variables.project.index'),
'search_text' => 'shared variables project environment',
],
[
'name' => 'Environment Shared Variables',
'type' => 'navigation',
'description' => 'Manage environment shared variables',
'link' => route('shared-variables.environment.index'),
'search_text' => 'shared variables environment',
],
[
'name' => 'Tags',
'type' => 'navigation',
'description' => 'View resources by tags',
'link' => route('tags.show'),
'search_text' => 'tags labels organize',
],
$user->can('canAccessTerminal') ? [
'name' => 'Terminal',
'type' => 'navigation',
'description' => 'Access server terminal',
'link' => route('terminal'),
'search_text' => 'terminal ssh console shell command line',
] : null,
[
'name' => 'Profile',
'type' => 'navigation',
'description' => 'Manage your profile and preferences',
'link' => route('profile'),
'search_text' => 'profile account user settings preferences',
],
[
'name' => 'Team',
'type' => 'navigation',
'description' => 'Manage team members and settings',
'link' => route('team.index'),
'search_text' => 'team settings members users invitations',
],
[
'name' => 'Notifications',
'type' => 'navigation',
'description' => 'Configure email, Discord, Telegram notifications',
'link' => route('notifications.email'),
'search_text' => 'notifications alerts email discord telegram slack pushover',
],
[
'name' => 'Analytics',
'type' => 'navigation',
'description' => 'View traffic analytics for your servers',
'link' => route('analytics'),
'search_text' => 'analytics traffic requests visitors metrics',
],
$user->isAdmin() ? [
'name' => 'Registries',
'type' => 'navigation',
'description' => 'Manage Docker registry credentials',
'link' => route('registries.index'),
'search_text' => 'registries docker registry credentials login private images',
] : null,
[
'name' => 'Server Shared Variables',
'type' => 'navigation',
'description' => 'Manage server shared variables',
'link' => route('shared-variables.server.index'),
'search_text' => 'shared variables server environment',
],
[
'name' => 'Team Members',
'type' => 'navigation',
'description' => 'Manage team members and invitations',
'link' => route('team.member.index'),
'search_text' => 'team members users invitations invite roles',
],
$user->isAdminOfTeam($team->id) ? [
'name' => 'Team Audit Log',
'type' => 'navigation',
'description' => 'View team activity history',
'link' => route('team.audit-log'),
'search_text' => 'team audit log activity history events',
] : null,
isInstanceAdmin() ? [
'name' => 'Team Admin View',
'type' => 'navigation',
'description' => 'Manage all users of this instance',
'link' => route('team.admin-view'),
'search_text' => 'team admin view users instance delete',
] : null,
$user->can('delete', $team) ? [
'name' => 'Team Danger Zone',
'type' => 'navigation',
'description' => 'Delete this team',
'link' => route('team.danger-zone'),
'search_text' => 'team danger zone delete remove',
] : null,
[
'name' => 'API Tokens',
'type' => 'navigation',
'description' => 'Manage API tokens',
'link' => route('security.api-tokens'),
'search_text' => 'security api tokens personal access token',
],
$user->can('viewAny', CloudProviderToken::class) ? [
'name' => 'Cloud Tokens',
'type' => 'navigation',
'description' => 'Manage cloud provider tokens',
'link' => route('security.cloud-tokens'),
'search_text' => 'security cloud provider tokens hetzner digitalocean',
] : null,
$user->can('viewAny', IntegrationToken::class) ? [
'name' => 'Integration Tokens',
'type' => 'navigation',
'description' => 'Manage integration tokens',
'link' => route('security.integration-tokens'),
'search_text' => 'security integration tokens',
] : null,
[
'name' => 'Discord Notifications',
'type' => 'navigation',
'description' => 'Configure Discord notifications',
'link' => route('notifications.discord'),
'search_text' => 'notifications alerts discord webhook',
],
[
'name' => 'Telegram Notifications',
'type' => 'navigation',
'description' => 'Configure Telegram notifications',
'link' => route('notifications.telegram'),
'search_text' => 'notifications alerts telegram bot',
],
[
'name' => 'Slack Notifications',
'type' => 'navigation',
'description' => 'Configure Slack notifications',
'link' => route('notifications.slack'),
'search_text' => 'notifications alerts slack webhook',
],
[
'name' => 'Pushover Notifications',
'type' => 'navigation',
'description' => 'Configure Pushover notifications',
'link' => route('notifications.pushover'),
'search_text' => 'notifications alerts pushover',
],
[
'name' => 'Webhook Notifications',
'type' => 'navigation',
'description' => 'Configure webhook notifications',
'link' => route('notifications.webhook'),
'search_text' => 'notifications alerts webhook http',
],
[
'name' => 'Appearance',
'type' => 'navigation',
'description' => 'Change theme and display preferences',
'link' => route('profile.appearance'),
'search_text' => 'profile appearance theme dark light mode preferences',
],
isCloud() && $user->isAdmin() ? [
'name' => 'Subscription',
'type' => 'navigation',
'description' => 'Manage your subscription and billing',
'link' => route('subscription.show'),
'search_text' => 'subscription billing plan payment invoices',
] : null,
])->filter();
// Add instance settings only for self-hosted and root team
if (! isCloud() && $team->id === 0) {
$navigation->push(
[
'name' => 'Settings',
'type' => 'navigation',
'description' => 'Instance settings and configuration',
'link' => route('settings.index'),
'search_text' => 'settings general configuration instance url domain name timezone public ip https redirect',
],
[
'name' => 'Advanced Settings',
'type' => 'navigation',
'description' => 'Registration, API access, MCP server, DNS validation and telemetry',
'link' => route('settings.advanced'),
'search_text' => 'settings advanced registration api access allowed ips mcp server dns validation custom dns domain connect confirmation telemetry sponsorship cdn',
],
[
'name' => 'Update Settings',
'type' => 'navigation',
'description' => 'Configure Coolify auto updates',
'link' => route('settings.updates'),
'search_text' => 'settings updates auto update upgrade version frequency',
],
[
'name' => 'Instance Backup',
'type' => 'navigation',
'description' => 'Back up the Coolify instance database',
'link' => route('settings.backup'),
'search_text' => 'settings instance backup coolify database restore',
],
[
'name' => 'Instance Email',
'type' => 'navigation',
'description' => 'Configure SMTP or Resend for system emails',
'link' => route('settings.email'),
'search_text' => 'settings instance email smtp resend transactional mail',
],
[
'name' => 'Authentication',
'type' => 'navigation',
'description' => 'Configure OAuth login providers',
'link' => route('settings.oauth'),
'search_text' => 'settings authentication oauth sso login providers github google gitlab azure',
],
);
}
return $navigation;
}
private function search()
+43
View File
@@ -4450,6 +4450,49 @@ function wireNavigate(): string
}
}
/**
* Flattens a grouped settings sidebar into the items that the command palette
* shows for the current page: every page, its child pages, and its in-page sections.
*
* @param iterable<string, iterable<array{label: string, route: string, navigate?: bool, visible?: bool, children?: array<int, array{label: string, route: string, navigate?: bool, visible?: bool}>}>> $groupedItems
* @param array<string, string> $routeParameters
* @param array<string, array<int, array{id: string, label: string}>> $pageSections In-page sections keyed by page route
* @return array<int, array{label: string, breadcrumb: string, search_text: string, href: string, navigate: bool}>
*/
function settingsSearchItems(iterable $groupedItems, array $routeParameters, array $pageSections = []): array
{
$spaNavigation = wireNavigate() !== '';
$items = [];
$add = function (string $label, string $breadcrumb, string $href, bool $navigate) use (&$items, $spaNavigation): void {
$items[] = [
'label' => $label,
'breadcrumb' => $breadcrumb,
'search_text' => $label.' '.$breadcrumb,
'href' => $href,
'navigate' => $navigate && $spaNavigation,
];
};
foreach ($groupedItems as $groupLabel => $groupItems) {
foreach ($groupItems as $item) {
$href = route($item['route'], $routeParameters);
$add($item['label'], $groupLabel, $href, $item['navigate'] ?? true);
foreach ($item['children'] ?? [] as $child) {
if ($child['visible'] ?? true) {
$add($child['label'], $groupLabel.' · '.$item['label'], route($child['route'], $routeParameters), $child['navigate'] ?? true);
}
}
foreach ($pageSections[$item['route']] ?? [] as $section) {
$add($section['label'], $groupLabel.' · '.$item['label'], $href.'#'.$section['id'], true);
}
}
}
return $items;
}
/**
* Redirect to a named route with SPA navigation support.
* Automatically uses wire:navigate when is_wire_navigate_enabled is true.
+22 -25
View File
@@ -4509,7 +4509,7 @@ html[data-theme="custom"] .runtime-log-columns {
.command-palette {
display: flex;
width: 100%;
max-width: 36rem;
max-width: 42rem;
flex-direction: column;
overflow: hidden;
border-radius: 10px;
@@ -4566,47 +4566,44 @@ html[data-theme="custom"] .runtime-log-columns {
border: 0 !important;
}
.command-palette-shortcuts {
display: flex;
/* Search scope switch (Global / This page), shown on resource settings pages */
.command-palette-scope {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 0.375rem;
gap: 0.125rem;
padding: 0.125rem;
border-radius: 0.5rem;
border: 1px solid var(--coollabs-hairline);
}
.command-palette-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
.command-palette-scope-option {
height: 1.375rem;
padding: 0 0.375rem;
padding: 0 0.5rem;
border-radius: 0.375rem;
border: 1px solid transparent;
background: var(--coollabs-recessed);
color: var(--coollabs-subtle);
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
font-family: inherit;
}
.dark .command-palette-kbd {
border-color: rgb(255 255 255 / 0.05);
background: rgb(255 255 255 / 0.06);
}
button.command-palette-kbd {
white-space: nowrap;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
button.command-palette-kbd:hover {
.command-palette-scope-option:hover {
color: #171717;
}
.command-palette-scope-option[aria-pressed='true'] {
color: #171717;
background: var(--coollabs-fill);
}
.dark button.command-palette-kbd:hover {
.dark .command-palette-scope-option:hover,
.dark .command-palette-scope-option[aria-pressed='true'] {
color: var(--color-fg);
}
.dark .command-palette-scope-option[aria-pressed='true'] {
background: rgb(255 255 255 / 0.1);
}
+44 -3
View File
@@ -34,10 +34,10 @@ document.addEventListener('alpine:init', initializeSettingsSidebarAccordionCompo
* after the scroll has settled. Starting the flash immediately makes long
* jumps (top → bottom) finish scrolling after the animation has already ended.
*
* @param {string} id
* @param {string|HTMLElement} target Section id or element
*/
window.scrollToSettingsSection = function scrollToSettingsSection(id) {
const el = document.getElementById(id);
window.scrollToSettingsSection = function scrollToSettingsSection(target) {
const el = typeof target === 'string' ? document.getElementById(target) : target;
if (!el) {
return;
}
@@ -136,6 +136,47 @@ window.scrollToSettingsSection = function scrollToSettingsSection(id) {
rafId = window.requestAnimationFrame(tick);
};
/**
* Items for the "This page" scope of the command palette: the pages of the
* settings sidebar (rendered by the server), then the visible sections of the
* current page. Sections that the sidebar already lists are skipped.
*
* @returns {Array<{label: string, breadcrumb: string, search_text: string, href: string, navigate?: boolean, element?: HTMLElement}>}
*/
window.currentPageSearchItems = function currentPageSearchItems() {
let items = [];
try {
items = JSON.parse(document.querySelector('[data-settings-search-items]')?.dataset.settingsSearchItems || '[]');
} catch (e) {
items = [];
}
const toPath = (href) => {
const url = new URL(href, window.location.href);
return url.pathname + url.hash;
};
const sidebarPaths = new Set(items.map((item) => toPath(item.href)));
const headingSelector = ':scope > header :is(h1, h2, h3), :scope > .application-settings-section-header :is(h1, h2, h3)';
document.querySelectorAll('.application-settings-section').forEach((section, index) => {
if (section.closest('[role=dialog], .command-palette') || section.getClientRects().length === 0) {
return;
}
if (section.id && sidebarPaths.has(window.location.pathname + '#' + section.id)) {
return;
}
const label = (section.dataset.settingsSectionTitle ?? section.querySelector(headingSelector)?.textContent ?? '')
.replace(/\s+/g, ' ')
.trim();
if (!label) {
return;
}
items.push({ label, breadcrumb: 'Section', search_text: label + ' section', href: 'section-' + index, element: section });
});
return items;
};
// When a settings sub-section link navigates across pages (href="route#section-id"),
// scroll to that section once the destination page has rendered.
function scrollToHashSettingsSection() {
@@ -9,21 +9,6 @@ export function initializeSettingsSidebarAccordionComponent() {
activeGroup: config.activeGroup || '',
storageKey: config.storageKey || 'coolify.settings-sidebar',
groups: {},
// Optional client-side filter (sidebars that render a search box).
search: '',
labels: Array.isArray(config.labels) ? config.labels : [],
get searching() {
return this.search.trim() !== '';
},
matches(label) {
if (!this.searching) {
return true;
}
return String(label).toLowerCase().includes(this.search.trim().toLowerCase());
},
get hasResults() {
return !this.searching || this.labels.some((label) => this.matches(label));
},
init() {
let stored = {};
try {
@@ -243,81 +243,23 @@
],
];
// Flat, searchable index: every page plus its in-page sub-sections. Each
// entry carries a breadcrumb (its category, and parent page for a
// sub-section) and combined text so the query matches sub-pages too.
$searchIndex = [];
foreach ($groupedMenuItems as $groupLabel => $groupItems) {
foreach ($groupItems as $item) {
$searchIndex[] = [
'label' => $item['label'],
'breadcrumb' => $groupLabel,
'searchText' => $item['label'].' '.$groupLabel,
'href' => route($item['route'], $applicationRouteParameters),
'icon' => $menuIcons[$item['label']] ?? 'settings',
'navigate' => $item['navigate'] ?? true,
];
foreach ($pageSections[$item['route']] ?? [] as $section) {
$searchIndex[] = [
'label' => $section['label'],
'breadcrumb' => $groupLabel.' · '.$item['label'],
'searchText' => $section['label'].' '.$item['label'].' '.$groupLabel,
'href' => route($item['route'], $applicationRouteParameters).'#'.$section['id'],
'icon' => $menuIcons[$item['label']] ?? 'settings',
'navigate' => true,
];
}
}
}
$searchTexts = array_column($searchIndex, 'searchText');
// Pages and in-page sections that the command palette (⌘K) can search on this page
$settingsSearchItems = settingsSearchItems($groupedMenuItems, $applicationRouteParameters, $pageSections);
@endphp
<aside @class([
'application-settings-navigation min-w-0 xl:self-start',
'is-flush' => $flush,
])>
]) data-settings-search-items="{{ json_encode($settingsSearchItems) }}">
<nav aria-label="Configuration sections"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.application', labels: @js($searchTexts) })"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.application' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
{{-- A quiet inline filter, deliberately lighter than the global ⌘K
search so the two don't read as duplicate search bars. --}}
<div class="relative col-span-full mb-1.5 xl:mb-2">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-model.debounce.100ms="search" type="search" placeholder="Filter settings"
aria-label="Filter settings"
class="h-7 w-full rounded-md border-0 bg-black/[0.035] py-0 pr-7 pl-7 text-[12px] text-nav-text shadow-none outline-none ring-0 transition-colors placeholder:text-neutral-400 focus:bg-black/[0.05] focus-visible:ring-1 focus-visible:ring-accent/40 dark:bg-white/[0.04] dark:text-fg dark:placeholder:text-fg-faint dark:focus:bg-white/[0.06]">
<button x-cloak x-show="searching" type="button" @click="search = ''" aria-label="Clear filter"
class="absolute top-1/2 right-1 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:text-black dark:text-fg-faint dark:hover:text-fg">
<x-reicon name="x" class="size-3" />
</button>
</div>
<p x-cloak x-show="searching && !hasResults"
class="col-span-full px-2.5 py-2 text-[12px] text-neutral-500 dark:text-fg-dim">
No settings match “<span x-text="search"></span>”.
</p>
{{-- Flat search results (pages + sub-sections), each with its category/parent. --}}
<div x-cloak x-show="searching" class="col-span-full flex flex-col gap-0.5">
@foreach ($searchIndex as $entry)
<a href="{{ $entry['href'] }}" @if ($entry['navigate']) {{ wireNavigate() }} @endif
x-show="matches(@js($entry['searchText']))"
class="group flex flex-col gap-0.5 rounded-md px-2.5 py-1.5 transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.05]">
<span class="flex min-w-0 items-center gap-2.5">
<x-reicon :name="$entry['icon']" class="size-[18px] shrink-0 text-nav-text opacity-90" />
<span class="truncate text-[13px] font-medium text-nav-text">{{ $entry['label'] }}</span>
</span>
<span class="truncate pl-[28px] text-[11px] text-neutral-400 dark:text-fg-faint">{{ $entry['breadcrumb'] }}</span>
</a>
@endforeach
</div>
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
x-show="!searching" aria-hidden="true"></div>
aria-hidden="true"></div>
@endunless
<button type="button" class="nav-section-toggle hidden xl:flex" x-show="!searching"
<button type="button" class="nav-section-toggle hidden xl:flex"
@click="toggle(@js($groupLabel))" :aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
@@ -329,8 +271,7 @@
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}"
x-show="!searching">
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}">
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
@@ -351,7 +292,7 @@
{{-- Sub-sections belong to the current page only; collapse them for
every other item so the sidebar stays short. --}}
@if ($menuItem['active'] && filled($sections))
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex" x-show="!searching"
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{ activeSection: '' }">
@foreach ($sections as $section)
<button type="button" class="menu-subitem"
@@ -5,7 +5,7 @@
'flush' => false,
])
<section {{ $attributes->merge(['class' => 'application-settings-section']) }}>
<section {{ $attributes->merge(['class' => 'application-settings-section', 'data-settings-section-title' => $title]) }}>
<header>
<div class="min-w-0 py-0.5">
@if (filled($description ?? $helper))
@@ -55,7 +55,8 @@
};
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Backup' => collect($items)->map(fn (array $item): array => [...$item, 'route' => $routes[$item['key']]])], $parameters)) }}">
<nav aria-label="Backup settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Backup</div>
@@ -63,7 +63,8 @@
: [];
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $databaseRouteParameters, ['project.database.configuration' => $pageSections])) }}">
<nav aria-label="Database settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.database' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
+8 -2
View File
@@ -55,12 +55,18 @@
{{-- Search is only useful when workspace resources are available --}}
@if (isSubscribed() || ! isCloud())
<div class="px-1 pb-3" :class="collapsed && 'lg:px-0'">
{{-- On pages with settings or sections the palette also searches the current page --}}
<button @click="$dispatch('open-global-search')" type="button"
:title="'Search (Press / or ' + modKeyLabel + 'K)'"
x-data="{ hasPageSearch: false }"
x-init="$nextTick(() => hasPageSearch = (window.currentPageSearchItems?.() ?? []).length > 0)"
:title="hasPageSearch
? 'Search this page and everything (Press / or ' + modKeyLabel + 'K, then Tab to switch)'
: 'Search (Press / or ' + modKeyLabel + 'K)'"
class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint">
<span class="flex items-center gap-2.5 min-w-0">
<x-reicon name="search" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Search</span>
<span class="menu-item-label truncate" :class="collapsed && 'lg:hidden'">Search<span
x-show="hasPageSearch" x-cloak class="text-[11px] opacity-70"> page &amp; global</span></span>
</span>
<kbd class="px-1.5 py-0.5 text-[11px] font-medium text-fg-faint bg-neutral-200 dark:bg-white/[0.06] rounded-md border border-transparent dark:border-white/5"
:class="collapsed && 'lg:hidden'" x-text="modKeyLabel + 'K'"></kbd>
@@ -15,7 +15,8 @@
<p class="settings-mobile-description">Configure how your team receives deployment and system alerts.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Notifications' => $notificationMenuItems], [])) }}">
<nav aria-label="Notification settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Notifications</div>
@@ -39,7 +39,8 @@
<p class="settings-mobile-description">Manage SSH keys, cloud credentials, and API access tokens.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Keys & Tokens' => $securityMenuItems], [])) }}">
<nav aria-label="Keys and tokens"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Keys & Tokens</div>
@@ -223,6 +223,7 @@
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedServerMenuItems, $serverRouteParameters)) }}"
x-data="{
proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),
traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo()),
@@ -52,7 +52,8 @@
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $serviceRouteParameters)) }}">
<nav aria-label="Service settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@@ -19,7 +19,8 @@
<p class="settings-mobile-description">Configure global settings for this Coolify instance.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($settingsMenuSections, [])) }}">
<nav aria-label="Instance settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($settingsMenuSections as $section => $menuItems)
@@ -15,7 +15,8 @@
</header>
<div class="grid min-w-0 gap-8 xl:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Shared variables' => $sharedVariablesMenuItems], [])) }}">
<nav aria-label="Shared variables"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($sharedVariablesMenuItems as $menuItem)
@@ -40,7 +40,8 @@
<p class="settings-mobile-description">Manage your team, members, and access settings.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Team' => $teamMenuItems], [])) }}">
<nav aria-label="Team settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Team</div>
@@ -22,7 +22,8 @@
])->filter();
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Destination' => $destinationMenuItems], $destinationRouteParameters)) }}">
<nav aria-label="Destination settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Settings</div>
+113 -19
View File
@@ -11,12 +11,10 @@
searchQuery: '',
creatableItems: [],
isCreateMode: false,
// macOS/iOS use ⌘; Windows/Linux use Ctrl+
modKeyLabel: (() => {
const platform = navigator.userAgentData?.platform || navigator.platform || '';
const ua = navigator.userAgent || '';
return /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X|Macintosh/i.test(ua) ? '⌘' : 'Ctrl+';
})(),
// Items of the open page: its sidebar pages and its sections. 'global' shows them
// above the global results; 'page' shows only them. Tab switches the scope.
pageItems: [],
scope: 'global',
serverTimingHudEnabled: localStorage.getItem('coolify.serverTimingHud.enabled') !== '0',
developerCommandsEnabled: @js(app()->environment('local')),
@@ -33,9 +31,60 @@
this.closeModal();
},
get pageResults() {
const query = this.searchQuery.toLowerCase().trim();
if (this.scope !== 'page' && !query) {
return [];
}
const results = this.pageItems.filter(item => item.search_text.toLowerCase().includes(query));
return this.scope === 'page' ? results : results.slice(0, 5);
},
setScope(scope) {
this.scope = scope;
this.selectedIndex = -1;
this.$refs.searchInput?.focus();
},
toggleScope() {
this.setScope(this.scope === 'page' ? 'global' : 'page');
},
// Mobile browsers (iOS Safari) open the keyboard only when focus happens inside the
// tap handler. The palette is still hidden here, so focus a temporary input now and
// move the focus to the search input when the palette shows. The keyboard stays open.
holdKeyboardFocus() {
const holder = document.createElement('input');
holder.setAttribute('aria-hidden', 'true');
holder.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;';
document.body.appendChild(holder);
holder.focus();
setTimeout(() => holder.remove(), 1000);
},
openPageItem(item) {
this.closeModal();
if (item.element) {
if (item.element.id) history.replaceState(null, '', '#' + item.element.id);
window.scrollToSettingsSection?.(item.element);
return;
}
const url = new URL(item.href, window.location.href);
if (url.pathname === window.location.pathname && url.hash) {
history.replaceState(null, '', url.hash);
window.scrollToSettingsSection?.(url.hash.slice(1));
} else if (item.navigate && window.Livewire?.navigate) {
window.Livewire.navigate(url.href);
} else {
window.location.assign(url.href);
}
},
// Client-side search function
get searchResults() {
if (!this.searchQuery || this.searchQuery.length < 1) {
if (this.scope === 'page' || !this.searchQuery || this.searchQuery.length < 1) {
return [];
}
@@ -55,7 +104,7 @@
},
get filteredCreatableItems() {
if (!this.searchQuery || this.searchQuery.length < 1) {
if (this.scope === 'page' || !this.searchQuery || this.searchQuery.length < 1) {
return [];
}
@@ -102,11 +151,14 @@
}
clearTimeout(this.closeResetTimer);
clearTimeout(this.spinnerTimer);
this.holdKeyboardFocus();
this.modalOpen = true;
this.selectedIndex = -1;
this.isLoadingInitialData = true;
this.showLoadingSpinner = false;
this.searchQuery = '';
this.pageItems = window.currentPageSearchItems?.() ?? [];
this.scope = 'global';
// Only show the spinner when loading takes longer than 150ms, so fast (cached) loads do not flash the icon
this.spinnerTimer = setTimeout(() => {
if (this.isLoadingInitialData) this.showLoadingSpinner = true;
@@ -207,7 +259,7 @@
'new postgresql', 'new postgres', 'new mysql', 'new mariadb',
'new redis', 'new keydb', 'new dragonfly', 'new mongodb', 'new mongo', 'new clickhouse', 'new sqlite'
];
if (exactMatchCommands.includes(trimmed)) {
if (this.scope === 'global' && exactMatchCommands.includes(trimmed)) {
const matchingItem = this.creatableItems.find(item => {
const itemSearchText = `new ${item.name}`.toLowerCase();
const itemType = `new ${item.type}`.toLowerCase();
@@ -271,6 +323,14 @@
}
}
};
// Capture phase, so the focus trap of the open palette does not move focus first
const tabKeyHandler = (e) => {
if (e.key !== 'Tab' || !this.modalOpen || this.pageItems.length === 0) return;
if (document.activeElement !== this.$refs.searchInput) return;
e.preventDefault();
e.stopImmediatePropagation();
this.toggleScope();
};
const arrowKeyHandler = (e) => {
if (!this.modalOpen) return;
if (e.key === 'ArrowDown') {
@@ -288,6 +348,7 @@
document.addEventListener('keydown', cmdKHandler);
document.addEventListener('keydown', escapeKeyHandler);
document.addEventListener('keydown', arrowKeyHandler);
document.addEventListener('keydown', tabKeyHandler, true);
// Cleanup on component destroy
this.$el.addEventListener('alpine:destroy', () => {
@@ -296,6 +357,7 @@
document.removeEventListener('keydown', cmdKHandler);
document.removeEventListener('keydown', escapeKeyHandler);
document.removeEventListener('keydown', arrowKeyHandler);
document.removeEventListener('keydown', tabKeyHandler, true);
});
// Watch for auto-open resource (only if $wire is available)
@@ -358,20 +420,21 @@
</svg>
</span>
<input type="text" x-model="searchQuery"
placeholder="Search resources, paths, everything (type new for create)..." x-ref="searchInput"
:placeholder="scope === 'page' ? 'Search this page…' : 'Search resources, paths, everything (type new for create)...'"
x-ref="searchInput"
x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 100) })"
class="command-palette-input" autocomplete="off" spellcheck="false" />
<div class="command-palette-shortcuts">
<span class="command-palette-kbd">/</span>
<span class="command-palette-kbd" x-text="modKeyLabel + 'K'"></span>
<button type="button" @click="closeModal()" class="command-palette-kbd" title="Close">
ESC
</button>
<div x-show="pageItems.length > 0" x-cloak class="command-palette-scope" role="group"
aria-label="Search scope" title="Press Tab to switch between global search and this page">
<button type="button" @click="setScope('global')" :aria-pressed="scope === 'global'"
class="command-palette-scope-option">Global</button>
<button type="button" @click="setScope('page')" :aria-pressed="scope === 'page'"
class="command-palette-scope-option">This page</button>
</div>
</div>
<!-- Search results -->
<div x-show="searchQuery.length >= 1" x-cloak class="command-palette-body relative">
<div x-show="searchQuery.length >= 1 || scope === 'page'" x-cloak class="command-palette-body relative">
@if (app()->environment('local'))
<div x-show="showServerTimingCommand && !$wire.isSelectingResource"
class="command-palette-section">
@@ -602,9 +665,31 @@
@endif
<div wire:ignore>
<template x-if="pageResults.length > 0 && !$wire.isSelectingResource">
<div class="command-palette-section">
<div class="command-palette-group-label">This page</div>
<template x-for="item in pageResults" :key="item.href">
<a :href="item.element ? (item.element.id ? '#' + item.element.id : '#') : item.href" @click.prevent="openPageItem(item)"
class="search-result-item command-palette-item">
<div class="command-palette-item-main">
<div class="command-palette-item-title">
<span class="command-palette-item-name" x-text="item.label"></span>
</div>
<div class="command-palette-item-meta" x-text="item.breadcrumb"></div>
</div>
<svg class="command-palette-item-chevron" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</template>
</div>
</template>
<template x-if="searchQuery.length >= 1 && searchResults.length > 0 && !$wire.isSelectingResource">
<div class="command-palette-section">
<template x-if="filteredCreatableItems.length > 0">
<template x-if="filteredCreatableItems.length > 0 || pageResults.length > 0">
<div class="command-palette-group-label">Existing resources</div>
</template>
<template x-for="(result, index) in searchResults" :key="index">
@@ -704,7 +789,7 @@
</template>
<template
x-if="searchQuery.length >= 2 && searchResults.length === 0 && filteredCreatableItems.length === 0 && !showServerTimingCommand && !$wire.isSelectingResource && !$wire.autoOpenResource && !isLoadingInitialData">
x-if="scope === 'global' && searchQuery.length >= 2 && pageResults.length === 0 && searchResults.length === 0 && filteredCreatableItems.length === 0 && !showServerTimingCommand && !$wire.isSelectingResource && !$wire.autoOpenResource && !isLoadingInitialData">
<div class="command-palette-empty">
<p class="command-palette-empty-title">No results found</p>
<p class="command-palette-empty-desc">
@@ -712,6 +797,15 @@
</p>
</div>
</template>
<template x-if="scope === 'page' && pageResults.length === 0">
<div class="command-palette-empty">
<p class="command-palette-empty-title">Nothing on this page</p>
<p class="command-palette-empty-desc">
Press <span class="font-medium">Tab</span> to search everything.
</p>
</div>
</template>
</div>
</div>
</div>
@@ -61,7 +61,8 @@
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $serviceRouteParameters)) }}">
<nav aria-label="Service settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@@ -43,7 +43,8 @@
@if ($showSettingsSidebar)
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['GitHub App' => $settingsMenuItems], $githubAppRouteParameters)) }}">
<nav aria-label="GitHub App settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Settings</div>
@@ -36,7 +36,8 @@
@if ($showSettingsSidebar)
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['S3 storage' => $settingsMenuItems], $storageRouteParameters)) }}">
<nav aria-label="S3 storage settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Settings</div>
@@ -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);
});