diff --git a/resources/js/components/ui/CommandPalette/Index.vue b/resources/js/components/ui/CommandPalette/Index.vue
deleted file mode 100644
index d373dd3084..0000000000
--- a/resources/js/components/ui/CommandPalette/Index.vue
+++ /dev/null
@@ -1,88 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- {{ __('Command Palette') }}
-
-
- {{ __('Search for content, navigate, and run actions.') }}
-
-
-
-
-
- {{ __('Actions') }}
-
-
-
-
-
- {{ __('Content results') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/views/partials/new-global-header.blade.php b/resources/views/partials/new-global-header.blade.php
index 3aff89ea9a..1ddf939890 100644
--- a/resources/views/partials/new-global-header.blade.php
+++ b/resources/views/partials/new-global-header.blade.php
@@ -46,7 +46,7 @@
-
+
diff --git a/routes/cp.php b/routes/cp.php
index e1f6554852..8317943534 100644
--- a/routes/cp.php
+++ b/routes/cp.php
@@ -39,6 +39,7 @@
use Statamic\Http\Controllers\CP\Collections\ReorderEntriesController;
use Statamic\Http\Controllers\CP\Collections\RestoreEntryRevisionController;
use Statamic\Http\Controllers\CP\Collections\ScaffoldCollectionController;
+use Statamic\Http\Controllers\CP\CommandPaletteController;
use Statamic\Http\Controllers\CP\CpController;
use Statamic\Http\Controllers\CP\DashboardController;
use Statamic\Http\Controllers\CP\DuplicatesController;
@@ -75,7 +76,6 @@
use Statamic\Http\Controllers\CP\Preferences\PreferenceController;
use Statamic\Http\Controllers\CP\Preferences\RolePreferenceController;
use Statamic\Http\Controllers\CP\Preferences\UserPreferenceController;
-use Statamic\Http\Controllers\CP\SearchController;
use Statamic\Http\Controllers\CP\SelectSiteController;
use Statamic\Http\Controllers\CP\SessionTimeoutController;
use Statamic\Http\Controllers\CP\Sites\SitesController;
@@ -301,7 +301,8 @@
Route::post('user-exists', UserWizardController::class)->name('user.exists');
- Route::get('search', SearchController::class)->name('search');
+ Route::get('command-palette', [CommandPaletteController::class, 'index'])->name('command-palette.index');
+ Route::get('command-palette/search', [CommandPaletteController::class, 'search'])->name('command-palette.search');
Route::get('utilities', [UtilitiesController::class, 'index'])->name('utilities.index');
Utility::routes();
diff --git a/src/CP/Navigation/Nav.php b/src/CP/Navigation/Nav.php
index 27b4ebee96..47eb08f60e 100644
--- a/src/CP/Navigation/Nav.php
+++ b/src/CP/Navigation/Nav.php
@@ -128,19 +128,12 @@ protected function removeChildItem($section, $name, $childName)
* @param mixed $preferences
* @return \Illuminate\Support\Collection
*/
- public function build($preferences = true, $withHidden = false)
+ public function build($preferences = true, bool $editing = false, bool $commands = false)
{
- return (new NavBuilder($this->makeBaseItems(), $withHidden))->build($preferences);
- }
-
- /**
- * Build navigation without applying preferences.
- *
- * @return \Illuminate\Support\Collection
- */
- public function buildWithoutPreferences($withHidden = false)
- {
- return $this->build(false, $withHidden);
+ return (new NavBuilder($this->makeBaseItems()))
+ ->withHidden($editing)
+ ->withCommandPalette($commands)
+ ->build($preferences);
}
/**
diff --git a/src/CP/Navigation/NavBuilder.php b/src/CP/Navigation/NavBuilder.php
index 4219e56c2e..478ad5386f 100644
--- a/src/CP/Navigation/NavBuilder.php
+++ b/src/CP/Navigation/NavBuilder.php
@@ -4,7 +4,10 @@
use Exception;
use Illuminate\Support\Facades\Cache;
+use Statamic\CommandPalette\Category;
+use Statamic\CommandPalette\Link;
use Statamic\Facades\Blink;
+use Statamic\Facades\CommandPalette;
use Statamic\Facades\Preference;
use Statamic\Facades\User;
use Statamic\Support\Arr;
@@ -18,6 +21,7 @@ class NavBuilder
protected $items = [];
protected $pendingItems = [];
protected $withHidden = false;
+ protected $withCommandPalette = false;
protected $itemsWithChildrenClosures = [];
protected $sections = [];
protected $sectionsOriginalItemIds = [];
@@ -34,10 +38,34 @@ class NavBuilder
* @param array $items
* @param bool $withHidden
*/
- public function __construct($items, $withHidden = false)
+ public function __construct($items)
{
$this->items = $items;
+ }
+
+ /**
+ * Build with hidden items.
+ *
+ * @return $this
+ */
+ public function withHidden(bool $withHidden = false): self
+ {
$this->withHidden = $withHidden;
+
+ return $this;
+ }
+
+ /**
+ * Build with command palette.
+ *
+ * @param bool $withHidden
+ * @return $this
+ */
+ public function withCommandPalette(bool $withCommandPalette = false): self
+ {
+ $this->withCommandPalette = $withCommandPalette;
+
+ return $this;
}
/**
@@ -66,6 +94,7 @@ public function build($preferences = true)
->applyPreferenceOverrides($preferences)
->buildSections()
->blinkUrls()
+ ->addToCommandPalette()
->get();
}
@@ -1048,6 +1077,60 @@ public static function clearCachedUrls()
Blink::forget(static::ALL_URLS_CACHE_KEY);
}
+ /**
+ * Add built items to command palette.
+ */
+ protected function addToCommandPalette(): self
+ {
+ if (! $this->withCommandPalette) {
+ return $this;
+ }
+
+ $this->built
+ ->flatMap(fn ($section) => $section['items'])
+ ->filter(fn ($item) => $item->url())
+ ->each(fn ($item) => $this->addItemToCommandPalette($item));
+
+ return $this;
+ }
+
+ /**
+ * Add nav item and its children to command palette.
+ */
+ public function addItemToCommandPalette(NavItem $item)
+ {
+ CommandPalette::addCommand(static::transformToLink($item));
+
+ if ($children = $item->resolveChildren()->children()) {
+ $children->each(fn ($child) => CommandPalette::addCommand(static::transformToLink($child, $item)));
+ }
+ }
+
+ /**
+ * Transform nav item to valid command palette `Link` instance.
+ */
+ protected static function transformToLink(NavItem $item, ?NavItem $parentItem = null): Link
+ {
+ $displayItem = $parentItem ?? $item;
+
+ $text = $displayItem->section() !== 'Top Level'
+ ? __($displayItem->section()).' » '.__($displayItem->display())
+ : __($displayItem->display());
+
+ if ($parentItem) {
+ $text .= ' » '.__($item->display());
+ }
+
+ $link = new Link(
+ text: $text,
+ category: Category::Navigation,
+ );
+
+ return $link
+ ->url($item->url())
+ ->icon($item->icon());
+ }
+
/**
* Get built nav.
*
diff --git a/src/CP/Navigation/NavTransformer.php b/src/CP/Navigation/NavTransformer.php
index 2f1f8001eb..5f71ba4705 100644
--- a/src/CP/Navigation/NavTransformer.php
+++ b/src/CP/Navigation/NavTransformer.php
@@ -18,7 +18,10 @@ class NavTransformer
*/
public function __construct(array $submitted)
{
- $this->coreNav = Nav::buildWithoutPreferences(true);
+ $this->coreNav = Nav::build(
+ preferences: false,
+ editing: true,
+ );
$this->submitted = $this->removeEmptyCustomSections($submitted);
}
diff --git a/src/CommandPalette/Category.php b/src/CommandPalette/Category.php
new file mode 100644
index 0000000000..827e2867c4
--- /dev/null
+++ b/src/CommandPalette/Category.php
@@ -0,0 +1,17 @@
+map->value->all();
+ }
+}
diff --git a/src/CommandPalette/Command.php b/src/CommandPalette/Command.php
new file mode 100644
index 0000000000..932a521757
--- /dev/null
+++ b/src/CommandPalette/Command.php
@@ -0,0 +1,46 @@
+icon = $icon;
+
+ return $this;
+ }
+
+ public function keys(string $keys): static
+ {
+ $this->keys = $keys;
+
+ return $this;
+ }
+
+ public function type(): string
+ {
+ return Str::snake((new \ReflectionClass(get_called_class()))->getShortName());
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'type' => $this->type(),
+ 'category' => $this->category->value,
+ 'icon' => $this->icon,
+ 'keys' => $this->keys,
+ 'text' => $this->text,
+ ];
+ }
+}
diff --git a/src/CommandPalette/ContentSearchResult.php b/src/CommandPalette/ContentSearchResult.php
new file mode 100644
index 0000000000..7ac3926ef7
--- /dev/null
+++ b/src/CommandPalette/ContentSearchResult.php
@@ -0,0 +1,31 @@
+badge = $badge;
+
+ return $this;
+ }
+
+ public function reference(string $reference): static
+ {
+ $this->reference = $reference;
+
+ return $this;
+ }
+
+ public function toArray(): array
+ {
+ return array_merge(parent::toArray(), [
+ 'badge' => $this->badge,
+ 'reference' => $this->reference,
+ ]);
+ }
+}
diff --git a/src/CommandPalette/Link.php b/src/CommandPalette/Link.php
new file mode 100644
index 0000000000..4f6795b890
--- /dev/null
+++ b/src/CommandPalette/Link.php
@@ -0,0 +1,22 @@
+url = $url;
+
+ return $this;
+ }
+
+ public function toArray(): array
+ {
+ return array_merge(parent::toArray(), [
+ 'url' => $this->url,
+ ]);
+ }
+}
diff --git a/src/CommandPalette/Palette.php b/src/CommandPalette/Palette.php
new file mode 100644
index 0000000000..142ceceb94
--- /dev/null
+++ b/src/CommandPalette/Palette.php
@@ -0,0 +1,61 @@
+items = collect();
+ }
+
+ public function addCommand(Command $command): self
+ {
+ $this->items->push(
+ $this->validateCommandArray($command->toArray()),
+ );
+
+ return $this;
+ }
+
+ public function build(): Collection
+ {
+ return $this
+ ->buildActions()
+ ->buildHistory()
+ ->get();
+ }
+
+ protected function buildActions(): self
+ {
+ // TODO: Addressing actions in separate PR.
+
+ return $this;
+ }
+
+ protected function buildHistory(): self
+ {
+ // TODO: Set up ajax route for caching command palette history as user runs commands.
+
+ return $this;
+ }
+
+ public function validateCommandArray(array $command): array
+ {
+ throw_unless(is_string(Arr::get($command, 'type')), new \Exception('Must output command [type] string!'));
+ throw_unless(is_string(Arr::get($command, 'category')), new \Exception('Must output command [category] string!'));
+ throw_unless(is_string(Arr::get($command, 'text')), new \Exception('Must output command [text] string!'));
+
+ return $command;
+ }
+
+ public function get(): Collection
+ {
+ return $this->items;
+ }
+}
diff --git a/src/Facades/CP/Nav.php b/src/Facades/CP/Nav.php
index 485ec1ad99..3e1ef0b96e 100644
--- a/src/Facades/CP/Nav.php
+++ b/src/Facades/CP/Nav.php
@@ -16,7 +16,6 @@
* @method static NavItem|null findOrCreate(string $section, string $name)
* @method static self remove(string $section, $name = null)
* @method static Collection build()
- * @method static Collection buildWithoutPreferences()
* @method static void clearCachedUrls()
* @method static array items()
*
diff --git a/src/Facades/CommandPalette.php b/src/Facades/CommandPalette.php
new file mode 100644
index 0000000000..55a89c1906
--- /dev/null
+++ b/src/Facades/CommandPalette.php
@@ -0,0 +1,17 @@
+ Category::order(),
+ 'items' => CommandPalette::build(),
+ ];
+ }
+
+ public function search(Request $request)
+ {
+ return Search::index()
+ ->ensureExists()
+ ->search($request->query('q'))
+ ->get()
+ ->filter(function (Result $item) {
+ return ! empty($item->getCpUrl()) && User::current()->can('view', $item->getSearchable());
+ })
+ ->take(10)
+ ->map(function (Result $result) {
+ return (new ContentSearchResult(text: $result->getCpTitle(), category: Category::Search))
+ ->url($result->getCpUrl())
+ ->badge($result->getCpBadge())
+ ->reference($result->getReference())
+ // ->icon() // TODO: Make dynamic for entries/terms/users?
+ ->toArray();
+ })
+ ->values();
+ }
+}
diff --git a/src/Http/Controllers/CP/Preferences/Nav/DefaultNavController.php b/src/Http/Controllers/CP/Preferences/Nav/DefaultNavController.php
index b0fcedc41a..672dda1f62 100644
--- a/src/Http/Controllers/CP/Preferences/Nav/DefaultNavController.php
+++ b/src/Http/Controllers/CP/Preferences/Nav/DefaultNavController.php
@@ -20,9 +20,10 @@ public function edit()
{
$preferences = Preference::default()->get('nav');
- $nav = $preferences
- ? Nav::build($preferences, true)
- : Nav::buildWithoutPreferences(true);
+ $nav = Nav::build(
+ preferences: $preferences ?: false,
+ editing: true,
+ );
return $this->navBuilder($nav, [
'title' => __('Default'),
diff --git a/src/Http/Controllers/CP/Preferences/Nav/RoleNavController.php b/src/Http/Controllers/CP/Preferences/Nav/RoleNavController.php
index 61c70fe1cd..01b4fd7a7e 100644
--- a/src/Http/Controllers/CP/Preferences/Nav/RoleNavController.php
+++ b/src/Http/Controllers/CP/Preferences/Nav/RoleNavController.php
@@ -29,9 +29,10 @@ public function edit($handle)
$preferences = $role->getPreference('nav') ?? Preference::default()->get('nav');
- $nav = $preferences
- ? Nav::build($preferences, true)
- : Nav::buildWithoutPreferences(true);
+ $nav = Nav::build(
+ preferences: $preferences ?: false,
+ editing: true,
+ );
return $this->navBuilder($nav, [
'title' => __($role->title()),
diff --git a/src/Http/Controllers/CP/SearchController.php b/src/Http/Controllers/CP/SearchController.php
deleted file mode 100644
index e38e97ac48..0000000000
--- a/src/Http/Controllers/CP/SearchController.php
+++ /dev/null
@@ -1,32 +0,0 @@
-ensureExists()
- ->search($request->query('q'))
- ->get()
- ->filter(function (Result $item) {
- return ! empty($item->getCpUrl()) && User::current()->can('view', $item->getSearchable());
- })
- ->take(10)
- ->map(function (Result $result) {
- return [
- 'reference' => $result->getReference(),
- 'title' => $result->getCpTitle(),
- 'url' => $result->getCpUrl(),
- 'badge' => $result->getCpBadge(),
- ];
- })
- ->values();
- }
-}
diff --git a/tests/CP/Navigation/NavPreferencesTest.php b/tests/CP/Navigation/NavPreferencesTest.php
index bdeaffffe7..49e6f38e09 100644
--- a/tests/CP/Navigation/NavPreferencesTest.php
+++ b/tests/CP/Navigation/NavPreferencesTest.php
@@ -1694,17 +1694,17 @@ public function it_checks_active_status_on_moved_items()
$this->assertTrue($tags->isActive());
}
- private function buildNavWithPreferences($preferences, $withHidden = false)
+ private function buildNavWithPreferences($preferences, $editingHidden = false)
{
$this->actingAs(tap(Facades\User::make()->makeSuper())->save());
- return Facades\CP\Nav::build($preferences, $withHidden)->pluck('items', 'display');
+ return Facades\CP\Nav::build(preferences: $preferences, editing: $editingHidden)->pluck('items', 'display');
}
private function buildDefaultNav()
{
$this->actingAs(tap(Facades\User::make()->makeSuper())->save());
- return Facades\CP\Nav::buildWithoutPreferences()->pluck('items', 'display');
+ return Facades\CP\Nav::build(preferences: false)->pluck('items', 'display');
}
}
diff --git a/tests/CommandPalette/CommandPaletteTest.php b/tests/CommandPalette/CommandPaletteTest.php
new file mode 100644
index 0000000000..8cb242af3e
--- /dev/null
+++ b/tests/CommandPalette/CommandPaletteTest.php
@@ -0,0 +1,85 @@
+andReturn(collect());
+ }
+
+ #[Test]
+ public function it_builds_an_array_that_can_be_converted_to_json()
+ {
+ // Todo: Fix
+ $this->markTestSkipped();
+
+ $this
+ ->actingAs(tap(User::make()->makeSuper())->save())
+ ->get(cp_route('dashboard'))
+ ->assertStatus(200);
+
+ $commands = CommandPalette::build();
+
+ $this->assertTrue(is_array($commands));
+ $this->assertTrue(is_string(json_encode($commands)));
+ }
+
+ #[Test]
+ public function it_can_build_commands_off_nav_items()
+ {
+ // TODO: Fix and flesh out coverage for nav children.
+ $this->markTestSkipped();
+
+ $this
+ ->actingAs(tap(User::make()->makeSuper())->save())
+ ->get(cp_route('dashboard'))
+ ->assertStatus(200);
+
+ $navigationCommands = collect(CommandPalette::build())
+ ->filter(fn ($item) => $item['category'] === 'Navigation')
+ ->map(fn ($item) => Arr::except($item, 'category'))
+ ->all();
+
+ $expected = [
+ ['type' => 'link', 'text' => 'Top Level > Dashboard', 'url' => 'http://localhost/cp/dashboard'],
+ ['type' => 'link', 'text' => 'Content > Collections', 'url' => 'http://localhost/cp/collections'],
+ ['type' => 'link', 'text' => 'Content > Navigation', 'url' => 'http://localhost/cp/navigation'],
+ ['type' => 'link', 'text' => 'Content > Taxonomies', 'url' => 'http://localhost/cp/taxonomies'],
+ ['type' => 'link', 'text' => 'Content > Assets', 'url' => 'http://localhost/cp/assets'],
+ ['type' => 'link', 'text' => 'Content > Globals', 'url' => 'http://localhost/cp/globals'],
+ ['type' => 'link', 'text' => 'Fields > Blueprints', 'url' => 'http://localhost/cp/fields/blueprints'],
+ ['type' => 'link', 'text' => 'Fields > Fieldsets', 'url' => 'http://localhost/cp/fields/fieldsets'],
+ ['type' => 'link', 'text' => 'Tools > Forms', 'url' => 'http://localhost/cp/forms'],
+ ['type' => 'link', 'text' => 'Tools > Updates', 'url' => 'http://localhost/cp/updater'],
+ ['type' => 'link', 'text' => 'Tools > Addons', 'url' => 'http://localhost/cp/addons'],
+ ['type' => 'link', 'text' => 'Tools > Utilities', 'url' => 'http://localhost/cp/utilities'],
+ ['type' => 'link', 'text' => 'Tools > GraphQL', 'url' => 'http://localhost/cp/graphql'],
+ ['type' => 'link', 'text' => 'Settings > Site', 'url' => 'http://localhost/cp/sites'],
+ ['type' => 'link', 'text' => 'Settings > Preferences', 'url' => 'http://localhost/cp/preferences'],
+ ['type' => 'link', 'text' => 'Users > Users', 'url' => 'http://localhost/cp/users'],
+ ['type' => 'link', 'text' => 'Users > Groups', 'url' => 'http://localhost/cp/user-groups'],
+ ['type' => 'link', 'text' => 'Users > Permissions', 'url' => 'http://localhost/cp/roles'],
+ ];
+
+ $this->assertEquals($expected, $navigationCommands);
+ }
+}
diff --git a/tests/Feature/GlobalSearch/GlobalSearchTest.php b/tests/CommandPalette/ContentSearchTest.php
similarity index 96%
rename from tests/Feature/GlobalSearch/GlobalSearchTest.php
rename to tests/CommandPalette/ContentSearchTest.php
index 5ad08f64c9..ec5f5b0331 100644
--- a/tests/Feature/GlobalSearch/GlobalSearchTest.php
+++ b/tests/CommandPalette/ContentSearchTest.php
@@ -1,6 +1,6 @@