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:
+307
-137
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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]">
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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