Skip to content

feat: D4 · Navigation editor screen + core/navigation enablement - #377

Merged
ViewFromTheBox merged 2 commits into
release/1.0from
feature/371-d4-nav-editor
Apr 25, 2026
Merged

ViewFromTheBox merged 2 commits into
release/1.0from
feature/371-d4-nav-editor

Conversation

@ViewFromTheBox

@ViewFromTheBox ViewFromTheBox commented Apr 25, 2026 •

Copy link
Copy Markdown
Member

Description

Builds the Navigation editor screen inside the D1 site-editor shell per design brief §3.8 and enables the core/navigation block in the template canvas. The navigation section is the one site-editor surface that deliberately ISN'T a block-editor iframe — nav trees are structural data, not block trees, so this ships a native tree editor with drag-sort + nested re-parenting + a typed-target picker.

Closes: #371

Type of Change

  • New feature (adds new functionality)

Related Issue

Issue: #371

Motivation and Context

Navigation is called out in V1 plan §4.3 as "the hardest single piece" of Phase D — core/navigation's editor experience depends on a substantial core-data shim surface (the wp_navigation entity, the link-control menu picker, fallback resolution). C4 (#360) landed the backend; B1 (#353) locked the shim. D4 is the UI surface that depends on both, and the section that actually lets users build menus.

Changes Made

Backend

  • New nullable + indexed location column on visual_editor_navigations so the editor can write menu-location assignments (single-occupant: assigning a location to one menu releases it from any prior owner)
  • MenuLocationsController — GET /menu-locations, returning the configured slugs paired with the menu currently resolved for each + an is_fallback flag so the UI can render the "falling back to X" hint
  • EntitySearchController — GET /search?type=…&q=…, the link-picker's source for pages / posts / templates / template-parts. Searches use a portable LIKE … ESCAPE '|' pattern and cap at 20 results per request
  • MenuLocationResolver now prefers DB assignments over the config primary_id chain, with a new effectiveAssignments() helper for cross-location reads
  • Form-request validation rejects unknown location slugs as 422 so the editor can never write to a slot the front-end has no theme hook for

Frontend (resources/js/visual-editor/site-editor/navigation/)

  • menu-tree.ts — pure functions for the Gutenberg-block ↔ typed-MenuItem-tree bridge and the flat-list / projection helpers powering DnD (flattenTree, buildTreeFromFlat, getDescendantIds, projectDrop, commitProjection)
  • navigation-canvas.tsx — the native tree editor. Single sortable list with depth-by-padding; drag X-offset projects target depth (clamped to previous-sibling-depth + 1 and next-sibling-depth) so users drag right to nest into a submenu and left to outdent back to root. Subtrees move with their parent
  • navigation-inspector.tsx + link-picker.tsx — entity panel (name, slug, location dropdown) and item panel (typed link picker, label override, advanced rel/class/new-tab). All form fields use the shared .ap-site-editor__dialog-* classes so they match the templates / parts inspector
  • navigation-browser.tsx + locations-panel.tsx — left-rail menu list + locations assignment panel
  • create-menu-dialog.tsx — slug + name + optional location, also using the shared dialog chrome
  • navigation-section.tsx — orchestrator that returns { navigator, canvas, inspector, overlay } to the shell
  • core/navigation block enabled (removed from disabled_blocks config and the JS shell mirror)

How Has This Been Tested?

Testing Environment:

  • Operating System: macOS Darwin 25.4.0
  • Browser: tested in Chrome via Laravel Herd dev app
  • PHP Version: 8.4.3
  • Project Version: 1.0.0

Tests Performed:

  1. Created a menu via "Add new", added page / post / custom-URL items, edited each in the inspector, saved — round-tripped cleanly through PUT /navigation/{id}
  2. Drag-sorted top-level items, dragged a top-level item right onto another to nest, dragged a nested item left to outdent, dragged a parent with children (subtree moved as a unit)
  3. Assigned a menu to a location via the inspector dropdown — assignment persisted, locations panel rendered the new resolution
  4. Reassigned the same location to another menu — first menu's location cleared (single-occupant)
  5. Switched to the Templates section and confirmed core/navigation is now in the inserter

Accessibility Tests Run

  • Keyboard navigation tested
  • ARIA labels checked
  • Screen reader tested (deferred — not in V1 scope per design brief)
  • Color contrast verified (uses existing site-editor design tokens)

Details: All interactive elements (drag handle, row main, action buttons, picker results) are buttons with descriptive aria-labels; the tree uses role="tree" / role="treeitem" with aria-level and aria-selected; focus-visible outlines are explicit on the row main + action buttons.

Tests Added

  • Unit tests added/updated
  • Integration tests added/updated
  • All tests passing

Test details:

  • 22 new menu-tree tests (block round-trip, mutation helpers, flatten / build, projection clamping, drag-out-to-root commit)
  • 13 new component / API tests across navigation-canvas, navigation-section, link-picker, and api-client
  • 2 new PHP test files (MenuLocationsControllerTest, EntitySearchControllerTest) plus extensions to MenuLocationResolverTest (DB-priority + effectiveAssignments) and NavigationControllerTest (location persistence, slug validation, single-occupant reassignment, null-clears)
  • All 1059 tests pass: 356 PHP + 703 JS (1 pre-existing skip)

Documentation

Documentation details: Each new file carries a header docblock with rationale; the menu-tree → block bridge and the DnD projection helpers carry inline comments naming the standard dnd-kit "tree" recipe they implement.

Out of scope (called out in plan / #371 but deferred)

  • Auto-generate menu from page tree — design brief §3.8 mentions this as an opt-in at create time. Requires a page-tree-source API B1 didn't deliver; defer to a follow-up issue rather than expand B1 mid-stream
  • core/navigation block link picker — when the block is placed in a template, its own LinkControl uses WordPress's /wp/v2/search endpoint, which the B1 shim doesn't expose. Block placement + render works; richer in-block link picking is a B1-shim follow-up

Pre-Submission Checklist

  • Followed contributing guidelines
  • Checked for other open PRs for same update
  • Code passes all tests
  • Code has been linted (TypeScript clean for new code; pre-existing project errors unchanged)
  • Accessibility tests completed
  • Code follows project style guide
  • Self-review completed
  • Comments added for complex code
  • No new warnings generated

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Comprehensive navigation menu editor (drag‑and‑drop, inspector, browser, create dialog, link picker, locations panel) and integration into the site editor UI
    • Searchable entity picker for linking menu items
  • API

    • Added read-only endpoints for menu locations and entity search
  • Database

    • Migration to track menu location assignments
  • Tests

    • Extensive test coverage across editor UI, API client, search, and location resolution

Builds the Navigation section inside the D1 site-editor shell per
design brief §3.8 and enables the `core/navigation` block in the
template canvas. Backend additions: a `location` column on
navigations with single-occupant assignment semantics, a
`MenuLocationsController` (read-only locations + effective-menu
resolution), and an `EntitySearchController` backing the
link-control picker. Frontend: a native tree editor (drag-sort with
nested re-parenting via the dnd-kit "tree" recipe), entity + item
inspector panels, locations panel, and create-menu dialog — all
sharing the existing `.ap-site-editor__dialog-*` form-field styles.

Closes #371

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 25, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a full Navigation (D4) editor: backend APIs for menu locations and entity search, DB-backed per-location assignments, controller/validation updates, and a complete frontend navigation section including browser, canvas tree editor, inspector, API client, utilities, styles, tests, and site-editor integration. Also removes core/navigation from the disabled-blocks list.

Changes

Cohort / File(s) Summary
Config
config/visual-editor.php
Removes core/navigation from disabled_blocks and adds documentation comment about wp_navigation shim / REST support.
Database Migration
database/migrations/2026_04_24_000000_add_location_to_visual_editor_navigations_table.php
Adds nullable location column with unique index and migration rollback.
Routes
routes/api.php
Adds GET /menu-locations and GET /search endpoints under visual-editor API.
Controllers
src/Http/Controllers/MenuLocationsController.php, src/Http/Controllers/EntitySearchController.php, src/Http/Controllers/NavigationController.php
New menu-locations and search controllers; NavigationController updated to normalize location, release conflicting assignments, and persist within DB transactions.
Requests / Validation
src/Http/Requests/StoreNavigationRequest.php, src/Http/Requests/UpdateNavigationRequest.php
Add optional location validation ensuring slug belongs to configured locations.
Models & Services
src/Models/VisualEditorNavigation.php, src/Services/MenuLocationResolver.php
Make location fillable; add scopeForLocation; resolver prefers DB-assigned navigation first and exposes effectiveAssignments().
API Resource
src/Http/Resources/NavigationResource.php
Include location in JSON output, normalizing empty string to null.
Frontend API Client
resources/js/visual-editor/site-editor/navigation/api-client.ts
New typed client: list/fetch/create/update navigations, menu-locations, search, standardized headers, CSRF handling, error normalization, and runtime shape validation.
Menu model & DnD utilities
resources/js/visual-editor/site-editor/navigation/menu-tree.ts
Adds MenuItem types, blocks↔tree conversion, immutable tree ops, flatten/build for dnd-kit, drop projection/commit utilities, and helpers.
UI Components
resources/js/visual-editor/site-editor/navigation/...
New components: NavigationBrowser, NavigationCanvas, NavigationInspector, CreateMenuDialog, LinkPicker, LocationsPanel; corresponding CSS files added.
Editor Hook & Section
resources/js/visual-editor/site-editor/navigation/use-navigation-editor.ts, navigation-section.tsx
Hook manages loading, tree conversion, dirty/save lifecycle with epoch/cancellation; useNavigationSectionViews composes navigator/canvas/inspector/overlay and handles location assignment flows.
App Integration
resources/js/visual-editor/site-editor/site-editor-app.tsx
Registers navigation as D4 section, integrates views, and removes core/navigation from disabled-blocks.
Frontend Tests
resources/js/visual-editor/site-editor/navigation/__tests__/*
Comprehensive Vitest suites for api-client, link-picker, menu-tree, navigation-canvas, and navigation-section.
Backend Tests
tests/Feature/VisualEditor/*
Feature tests for EntitySearchController, MenuLocationsController, MenuLocationResolver behavior, and Navigation location create/update/clear/validation.

Sequence Diagram

sequenceDiagram
    actor User
    participant Canvas as NavigationCanvas (UI)
    participant Editor as useNavigationEditor (Hook)
    participant API as api-client.ts
    participant Backend as Laravel Controllers
    participant DB as Database

    User->>Canvas: Open menu / Edit tree / Click Save
    Canvas->>Editor: save()
    Editor->>API: updateNavigation(id, payload)
    API->>Backend: PUT /visual-editor/api/navigation/{id}
    Backend->>DB: START TRANSACTION\n(ensure unique location: clear other records)
    Backend->>DB: UPDATE visual_editor_navigations SET ...
    Backend->>DB: COMMIT
    Backend->>API: 200 JSON NavigationRecord
    API->>Editor: return NavigationRecord
    Editor->>Canvas: update state (lastSavedAt, saveStatus)

    User->>Canvas: Drag item / Project drop
    Canvas->>Canvas: flattenTree() -> projectDrop -> commitProjection
    Canvas->>Editor: setTree(newTree)
    Editor->>Editor: mark dirty / prepare serialized blocks

    User->>Canvas: Open LinkPicker & type query
    Canvas->>API: searchEntities({type,q})
    API->>Backend: GET /visual-editor/api/search?type=...&q=...
    Backend->>DB: SELECT ... LIMIT 20
    Backend->>API: return results
    API->>Canvas: filtered SearchResult[]
    Canvas->>Editor: set selected target on choose
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly related PRs

Poem

🐰 In the burrow of blocks I hop and I edit,

Menus take root where locations are credited.
Drag, drop, and search—links find their way,
DB fences set home for menus to stay.
A carrot of code — hop! — the editor's ready.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main changes: adding a D4 Navigation editor screen and enabling the core/navigation block.
Linked Issues check ✅ Passed The PR comprehensively implements the requirements from #371: navigation editor section, menu list view, link picker, nested items, location assignments, create-new flow, save pipeline, and extensive testing.
Out of Scope Changes check ✅ Passed All changes are within scope: navigation UI/logic, API clients, database schema for location column, backend endpoints and controllers, and comprehensive tests—no unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/371-d4-nav-editor

Comment @coderabbitai help to get the list of available commands and usage tips.

@ViewFromTheBox ViewFromTheBox moved this from Backlog to In Review in ArtisanPack UI Overview Apr 25, 2026
@ViewFromTheBox ViewFromTheBox linked an issue Apr 25, 2026 that may be closed by this pull request
11 tasks
@ViewFromTheBox
ViewFromTheBox marked this pull request as ready for review April 25, 2026 01:44

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (11)
tests/Feature/VisualEditor/MenuLocationResolverTest.php (1)

240-262: effectiveAssignments() test should assert all configured locations.

The test title promises per-location reporting, but it only verifies primary. Add footer/count assertions so regressions aren’t missed.

Suggested assertion tightening
 expect( $assignments )->toHaveKey( 'primary' )
+		->toHaveKey( 'footer' )
+		->toHaveCount( 2 )
 		->and( $assignments['primary']->id )->toBe( $primary->id );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/Feature/VisualEditor/MenuLocationResolverTest.php` around lines 240 -
262, The test for MenuLocationResolver::effectiveAssignments currently only
checks the 'primary' location; update the spec in MenuLocationResolverTest.php
to assert all configured locations are reported by asserting the $assignments
array has count 2, hasKey('footer') in addition to hasKey('primary'), and that
$assignments['footer'] is null (or otherwise reflects an unassigned location) so
the test verifies both presence and expected value for the footer location as
well as the primary assignment.
resources/js/visual-editor/site-editor/site-editor-app.tsx (1)

264-275: Prefer a single shared “open entity” callback.

Lines 270-275 duplicate the logic later in Lines 330-335. Hoisting one shared callback above the hook calls would reduce drift risk.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/site-editor/site-editor-app.tsx` around lines 264
- 275, There are two duplicated callbacks—handleNavigationOpen and
handleOpenEntity—that both call routing.navigate(activeSection.id, entityId);
remove the duplicate by hoisting a single shared callback (e.g., declare
handleOpenEntity above the hook calls) and use that same function where
handleNavigationOpen is currently passed to the navigation views hook; update
references to call handleOpenEntity (or rename consistently) and delete the
unused handleNavigationOpen to avoid TDZ issues and drift.
src/Http/Requests/UpdateNavigationRequest.php (1)

94-119: Consider extracting locationSlugRule() into a shared rule class.

This closure is mirrored in another request class; duplicating slug-validation logic in multiple places increases drift risk over time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Requests/UpdateNavigationRequest.php` around lines 94 - 119, The
duplicate closure in locationSlugRule() should be extracted into a reusable
validation rule class (e.g., MenuLocationRule) that encapsulates the logic of
resolving MenuLocationResolver and checking the value against
array_keys($resolver->locations()); implement the Laravel validation Rule
interface (or make it invokable) so both StoreNavigationRequest and
UpdateNavigationRequest can use new MenuLocationRule() instead of duplicating
the closure, and update the requests to replace calls to locationSlugRule() with
the new rule class.
resources/js/visual-editor/site-editor/navigation/navigation-canvas.tsx (1)

439-453: Consider removing aria-pressed from the row selection button.

The parent <li role="treeitem"> already has aria-selected (line 338), which is the semantically correct attribute for tree item selection. The aria-pressed on this inner button (line 443) is typically for toggle buttons that stay pressed, which could confuse screen readers. Since the treeitem selection is already announced via aria-selected, this inner button could use aria-current or simply omit the pressed state.

This is a minor accessibility refinement, not a blocker.

♿ Consider removing aria-pressed
             <button
                 type="button"
                 className="ap-nav-canvas__row-main"
                 onClick={handleSelect}
-                aria-pressed={isSelected}
                 data-testid={`ap-nav-canvas-row-${item.localId}`}
             >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/site-editor/navigation/navigation-canvas.tsx`
around lines 439 - 453, Remove the incorrect aria-pressed on the inner button
used for row selection: in the button element that uses onClick={handleSelect}
and data-testid={`ap-nav-canvas-row-${item.localId}`}, delete
aria-pressed={isSelected} (or replace it with aria-current if you want to
indicate the currently focused/active row) because the parent tree item already
exposes selection via aria-selected; keep handleSelect and other attributes
unchanged.
resources/js/visual-editor/site-editor/navigation/navigation-browser.tsx (1)

124-127: itemCountForRow recalculates on each render.

This helper function is defined inside the component and recalculates for each row on every render. For typical menu counts (under 50 rows with shallow trees), this is fine. If performance becomes a concern, it could be memoized per row.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/site-editor/navigation/navigation-browser.tsx`
around lines 124 - 127, The helper itemCountForRow currently defined inside the
component recalculates on every render; change it to a memoized implementation
so counts are computed only when a row's blocks change—e.g., replace the inline
const itemCountForRow = (row) => countNavigationLeaves(row.content.blocks) with
a memoized function using useCallback or compute a memo Map via useRef/useMemo
keyed by row id (or row.content.blocks) and call countNavigationLeaves only when
that key changes; reference the existing itemCountForRow and
countNavigationLeaves symbols and ensure dependencies include the specific row
content so repeated renders reuse cached counts.
src/Http/Controllers/NavigationController.php (1)

127-132: Consider atomicity: releaseLocationFromOtherRecords runs before save().

If two concurrent requests try to assign the same location to different navigations, both could pass the release step before either commits their save. This could result in duplicate location assignments until the next assignment/release cycle.

For a V1 admin UI this is likely low-risk, but for robustness you could either:

  1. Wrap the release + save in a transaction, or
  2. Move the release call after save() (though this briefly leaves duplicates).

Given the resolver's forLocation already handles the "multiple records" case by picking the first match, this is acceptable as-is for V1.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Controllers/NavigationController.php` around lines 127 - 132, The
current flow in NavigationController calls
releaseLocationFromOtherRecords($navigation) before $navigation->save(), which
can allow a race where two concurrent requests both release and then save the
same location; to fix, wrap the release + save in a single database transaction
(use DB::transaction or the app's transaction helper) so
releaseLocationFromOtherRecords($navigation) and $navigation->save() are atomic,
or alternatively move the call to releaseLocationFromOtherRecords($navigation)
to after $navigation->save() if you prefer a simpler approach; update the code
around the releaseLocationFromOtherRecords and save calls to use the transaction
API or reorder them accordingly.
resources/js/visual-editor/site-editor/navigation/create-menu-dialog.tsx (1)

136-139: Unnecessary useMemo for static translation.

The titleLabel is just a call to __() with no dependencies. The empty dependency array means it only computes once, but useMemo adds overhead here without benefit — the translation string is stable.

♻️ Simplify by removing useMemo
-    const titleLabel = useMemo(
-        () => __('Menu name', TEXT_DOMAIN),
-        []
-    );
+    const titleLabel = __('Menu name', TEXT_DOMAIN);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/site-editor/navigation/create-menu-dialog.tsx`
around lines 136 - 139, The titleLabel variable is wrapped in an unnecessary
useMemo; replace the useMemo call with a direct assignment like const titleLabel
= __('Menu name', TEXT_DOMAIN) in create-menu-dialog.tsx (remove the useMemo
import usage if no longer used) so the static translation is computed directly
without memoization overhead.
resources/js/visual-editor/site-editor/navigation/navigation-inspector.tsx (1)

342-355: Redundant id on checkbox wrapped by label.

The <input> has id={newTabId} but it's already wrapped by the <label>, which provides implicit association. The id is unused since there's no external htmlFor reference.

♻️ Remove unused id
             <div className="ap-site-editor__dialog-field">
                 <label className="ap-nav-inspector__field-checkbox">
                     <input
-                        id={newTabId}
                         type="checkbox"
                         checked={item.opensInNewTab}
                         onChange={(event) =>
                             onPatch({ opensInNewTab: event.target.checked })
                         }
                         data-testid="ap-nav-inspector-new-tab"
                     />
                     {__('Open in new tab', TEXT_DOMAIN)}
                 </label>
             </div>

And remove the unused useId call:

     const labelId = useId();
     const classId = useId();
     const relId = useId();
-    const newTabId = useId();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/site-editor/navigation/navigation-inspector.tsx`
around lines 342 - 355, Remove the redundant id on the checkbox and the unused
useId call: in navigation-inspector.tsx remove id={newTabId} from the input
element (the checkbox is already implicitly associated by being wrapped in the
<label> within the Nav inspector component) and delete the corresponding useId
import and the newTabId variable (the useId() call) so there are no unused
identifiers remaining.
resources/js/visual-editor/site-editor/navigation/navigation-section.tsx (1)

186-232: Consider error handling granularity for location assignment.

The handleAssignLocation catches errors and sets locationsError, but this error persists until the next locations fetch. If the user tries another assignment, the error from the previous attempt remains visible. Consider clearing locationsError at the start of the handler.

♻️ Clear error before assignment attempt
     const handleAssignLocation = useCallback(
         async (
             locationSlug: string,
             navigationId: number | null
         ): Promise<void> => {
+            setLocationsError(null);
+
             // Two-step write semantics: the chosen menu gets the new
             // slug; whoever already had the slug is released by the
             // backend. If the user picked "None" we still need to find
             // the current owner and clear it.
             try {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/site-editor/navigation/navigation-section.tsx`
around lines 186 - 232, The handler handleAssignLocation leaves previous errors
visible because setLocationsError is only called on catch; clear the prior error
at the start of the function (before the try) by calling setLocationsError with
the "no error" value used in this component (e.g., null or empty string), so the
UI resets when a new assignment begins; update handleAssignLocation (and
reference setLocationsError) accordingly.
src/Http/Controllers/EntitySearchController.php (1)

61-73: Unused closure parameters flagged by static analysis.

The $template and $part parameters in the URL resolver closures are unused. PHP 8+ allows omitting unused parameters with $_ or using no parameter when not needed.

♻️ Use shorter closure syntax
 protected function builtInSources(): array
 {
     return [
         'template'      => [
             'model' => VisualEditorTemplate::class,
-            'url'   => fn ( Model $template ): ?string => null,
+            'url'   => fn (): ?string => null,
         ],
         'template-part' => [
             'model' => VisualEditorTemplatePart::class,
-            'url'   => fn ( Model $part ): ?string => null,
+            'url'   => fn (): ?string => null,
         ],
     ];
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Controllers/EntitySearchController.php` around lines 61 - 73, The
builtInSources() method defines URL resolver arrow functions with unused
parameters ($template and $part) which static analysis flags; update the 'url'
entries for VisualEditorTemplate and VisualEditorTemplatePart to use no-argument
arrow functions (e.g., fn(): ?string => null) or a single intentionally unused
parameter like fn($_): ?string => null so the closures no longer declare unused
variables, keeping the surrounding array keys and model references
(builtInSources, VisualEditorTemplate, VisualEditorTemplatePart) intact.
resources/js/visual-editor/site-editor/navigation/link-picker.tsx (1)

195-238: Consider keyboard navigation for listbox results.

The results use role="listbox" with role="option" children, but keyboard navigation (arrow keys, Enter to select) is not implemented. Currently users must click. This is acceptable for V1 but could be a follow-up accessibility improvement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/site-editor/navigation/link-picker.tsx` around
lines 195 - 238, The listbox results currently render an ul with role="listbox"
and li role="option" but have no keyboard navigation; add keyboard handling so
arrow keys move focus among results and Enter/Space selects the focused result.
Implement a focusedIndex state in the LinkPicker component, set appropriate
aria-activedescendant on the ul (or manage focus on the result buttons), add an
onKeyDown handler (e.g. handleResultsKeyDown) to capture
ArrowUp/ArrowDown/Home/End to update focusedIndex and preventDefault, and
trigger the same onChange call used in the existing onClick when Enter/Space is
pressed for the active result (use result.id, result.title, result.url and
item.url as in the current onClick). Ensure the result buttons reflect
focus/aria-selected based on focusedIndex and maintain existing click behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@database/migrations/2026_04_24_000000_add_location_to_visual_editor_navigations_table.php`:
- Line 36: The migration currently creates a non-unique index on the location
column; change it to a unique index so only one row can claim a given location.
Replace the $table->string('location')->nullable()->index() call with
$table->string('location')->nullable()->unique() (and update the other
occurrence at the second $table->string('location') call too) so the migration
creates a unique constraint enforcing single-occupant semantics.

In `@resources/js/visual-editor/site-editor/navigation/api-client.ts`:
- Around line 327-338: The runtime guards are too permissive: isMenuLocation and
isSearchResult currently don't validate their nested fields (isMenuLocation
never checks the menu object, and isSearchResult never validates the url
object), so malformed API payloads slip through; update isMenuLocation to assert
that row.menu exists and matches the Menu shape (e.g., required string/id
fields, items array or expected properties) and update isSearchResult to assert
that row.url exists and matches the URL shape (e.g., required href string and
any other required properties), using the existing pattern (typeof checks and
nested type predicates) and the same Record<string,unknown> casting used in
isMenuLocation to reject rows missing or mis-typed menu/url fields so only
payloads that fully satisfy the exported contracts are accepted.

In `@resources/js/visual-editor/site-editor/navigation/menu-tree.ts`:
- Around line 152-165: blocksToMenuTree currently skips any block whose name
isn't NAV_LINK or NAV_SUBMENU, causing unsupported navigation child blocks to be
dropped; instead of continuing when name !== NAV_LINK && name !== NAV_SUBMENU,
push a passthrough MenuItem that preserves the original block data (e.g., tag it
as type 'unsupported' or 'raw' and include the original UnknownBlock under a
property like originalBlock) so the in-memory tree can round-trip unknown
children without loss; update any downstream code that expects MenuItem variants
to handle this new passthrough case.
- Around line 29-30: The MenuItemType mapping loses original post-type and
taxonomy identifiers; update the types and conversion helpers so
non-'page'/'post' post types and taxonomies round-trip: change MenuItemType and
related discriminants used by blockAttributesToType() to carry the original post
type or taxonomy slug (e.g., include a postType or taxonomy field in the union
instead of collapsing to 'custom'/'taxonomy'), update blockAttributesToType() to
return that specific identifier when encountering non-standard post types or
taxonomy links, and update menuItemTypeToAttributes() to serialize those
identifiers back into attributes (emit a type/postType or taxonomy property
rather than only { kind: 'taxonomy' }) so templates/template-parts and custom
post types keep their identity across edit/save cycles.

In `@src/Services/MenuLocationResolver.php`:
- Around line 146-160: The docblock for effectiveAssignments() is inaccurate: it
claims a location with no DB assignment and no config `primary_id` resolves to
null, but the method calls forLocation($slug) which can return a published
fallback VisualEditorNavigation; update the comment to reflect the actual
behavior (that effectiveAssignments() maps each location slug to whatever
forLocation() returns, which may be a VisualEditorNavigation fallback or null),
and ensure the description references the forLocation() behavior and keeps the
declared return type array<string, ?VisualEditorNavigation>; no code changes
required unless you prefer to change forLocation() behavior instead—if so, make
forLocation() return null in that specific case and update its docblock
accordingly.

---

Nitpick comments:
In `@resources/js/visual-editor/site-editor/navigation/create-menu-dialog.tsx`:
- Around line 136-139: The titleLabel variable is wrapped in an unnecessary
useMemo; replace the useMemo call with a direct assignment like const titleLabel
= __('Menu name', TEXT_DOMAIN) in create-menu-dialog.tsx (remove the useMemo
import usage if no longer used) so the static translation is computed directly
without memoization overhead.

In `@resources/js/visual-editor/site-editor/navigation/link-picker.tsx`:
- Around line 195-238: The listbox results currently render an ul with
role="listbox" and li role="option" but have no keyboard navigation; add
keyboard handling so arrow keys move focus among results and Enter/Space selects
the focused result. Implement a focusedIndex state in the LinkPicker component,
set appropriate aria-activedescendant on the ul (or manage focus on the result
buttons), add an onKeyDown handler (e.g. handleResultsKeyDown) to capture
ArrowUp/ArrowDown/Home/End to update focusedIndex and preventDefault, and
trigger the same onChange call used in the existing onClick when Enter/Space is
pressed for the active result (use result.id, result.title, result.url and
item.url as in the current onClick). Ensure the result buttons reflect
focus/aria-selected based on focusedIndex and maintain existing click behavior.

In `@resources/js/visual-editor/site-editor/navigation/navigation-browser.tsx`:
- Around line 124-127: The helper itemCountForRow currently defined inside the
component recalculates on every render; change it to a memoized implementation
so counts are computed only when a row's blocks change—e.g., replace the inline
const itemCountForRow = (row) => countNavigationLeaves(row.content.blocks) with
a memoized function using useCallback or compute a memo Map via useRef/useMemo
keyed by row id (or row.content.blocks) and call countNavigationLeaves only when
that key changes; reference the existing itemCountForRow and
countNavigationLeaves symbols and ensure dependencies include the specific row
content so repeated renders reuse cached counts.

In `@resources/js/visual-editor/site-editor/navigation/navigation-canvas.tsx`:
- Around line 439-453: Remove the incorrect aria-pressed on the inner button
used for row selection: in the button element that uses onClick={handleSelect}
and data-testid={`ap-nav-canvas-row-${item.localId}`}, delete
aria-pressed={isSelected} (or replace it with aria-current if you want to
indicate the currently focused/active row) because the parent tree item already
exposes selection via aria-selected; keep handleSelect and other attributes
unchanged.

In `@resources/js/visual-editor/site-editor/navigation/navigation-inspector.tsx`:
- Around line 342-355: Remove the redundant id on the checkbox and the unused
useId call: in navigation-inspector.tsx remove id={newTabId} from the input
element (the checkbox is already implicitly associated by being wrapped in the
<label> within the Nav inspector component) and delete the corresponding useId
import and the newTabId variable (the useId() call) so there are no unused
identifiers remaining.

In `@resources/js/visual-editor/site-editor/navigation/navigation-section.tsx`:
- Around line 186-232: The handler handleAssignLocation leaves previous errors
visible because setLocationsError is only called on catch; clear the prior error
at the start of the function (before the try) by calling setLocationsError with
the "no error" value used in this component (e.g., null or empty string), so the
UI resets when a new assignment begins; update handleAssignLocation (and
reference setLocationsError) accordingly.

In `@resources/js/visual-editor/site-editor/site-editor-app.tsx`:
- Around line 264-275: There are two duplicated callbacks—handleNavigationOpen
and handleOpenEntity—that both call routing.navigate(activeSection.id,
entityId); remove the duplicate by hoisting a single shared callback (e.g.,
declare handleOpenEntity above the hook calls) and use that same function where
handleNavigationOpen is currently passed to the navigation views hook; update
references to call handleOpenEntity (or rename consistently) and delete the
unused handleNavigationOpen to avoid TDZ issues and drift.

In `@src/Http/Controllers/EntitySearchController.php`:
- Around line 61-73: The builtInSources() method defines URL resolver arrow
functions with unused parameters ($template and $part) which static analysis
flags; update the 'url' entries for VisualEditorTemplate and
VisualEditorTemplatePart to use no-argument arrow functions (e.g., fn(): ?string
=> null) or a single intentionally unused parameter like fn($_): ?string => null
so the closures no longer declare unused variables, keeping the surrounding
array keys and model references (builtInSources, VisualEditorTemplate,
VisualEditorTemplatePart) intact.

In `@src/Http/Controllers/NavigationController.php`:
- Around line 127-132: The current flow in NavigationController calls
releaseLocationFromOtherRecords($navigation) before $navigation->save(), which
can allow a race where two concurrent requests both release and then save the
same location; to fix, wrap the release + save in a single database transaction
(use DB::transaction or the app's transaction helper) so
releaseLocationFromOtherRecords($navigation) and $navigation->save() are atomic,
or alternatively move the call to releaseLocationFromOtherRecords($navigation)
to after $navigation->save() if you prefer a simpler approach; update the code
around the releaseLocationFromOtherRecords and save calls to use the transaction
API or reorder them accordingly.

In `@src/Http/Requests/UpdateNavigationRequest.php`:
- Around line 94-119: The duplicate closure in locationSlugRule() should be
extracted into a reusable validation rule class (e.g., MenuLocationRule) that
encapsulates the logic of resolving MenuLocationResolver and checking the value
against array_keys($resolver->locations()); implement the Laravel validation
Rule interface (or make it invokable) so both StoreNavigationRequest and
UpdateNavigationRequest can use new MenuLocationRule() instead of duplicating
the closure, and update the requests to replace calls to locationSlugRule() with
the new rule class.

In `@tests/Feature/VisualEditor/MenuLocationResolverTest.php`:
- Around line 240-262: The test for MenuLocationResolver::effectiveAssignments
currently only checks the 'primary' location; update the spec in
MenuLocationResolverTest.php to assert all configured locations are reported by
asserting the $assignments array has count 2, hasKey('footer') in addition to
hasKey('primary'), and that $assignments['footer'] is null (or otherwise
reflects an unassigned location) so the test verifies both presence and expected
value for the footer location as well as the primary assignment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ac395f98-62c5-4d66-8b58-1ddb0c15e40c

📥 Commits

Reviewing files that changed from the base of the PR and between a584d01 and 472a7db.

📒 Files selected for processing (34)
  • config/visual-editor.php
  • database/migrations/2026_04_24_000000_add_location_to_visual_editor_navigations_table.php
  • resources/js/visual-editor/site-editor/navigation/__tests__/api-client.test.ts
  • resources/js/visual-editor/site-editor/navigation/__tests__/link-picker.test.tsx
  • resources/js/visual-editor/site-editor/navigation/__tests__/menu-tree.test.ts
  • resources/js/visual-editor/site-editor/navigation/__tests__/navigation-canvas.test.tsx
  • resources/js/visual-editor/site-editor/navigation/__tests__/navigation-section.test.tsx
  • resources/js/visual-editor/site-editor/navigation/api-client.ts
  • resources/js/visual-editor/site-editor/navigation/create-menu-dialog.tsx
  • resources/js/visual-editor/site-editor/navigation/link-picker.tsx
  • resources/js/visual-editor/site-editor/navigation/locations-panel.tsx
  • resources/js/visual-editor/site-editor/navigation/menu-tree.ts
  • resources/js/visual-editor/site-editor/navigation/navigation-browser.css
  • resources/js/visual-editor/site-editor/navigation/navigation-browser.tsx
  • resources/js/visual-editor/site-editor/navigation/navigation-canvas.css
  • resources/js/visual-editor/site-editor/navigation/navigation-canvas.tsx
  • resources/js/visual-editor/site-editor/navigation/navigation-inspector.css
  • resources/js/visual-editor/site-editor/navigation/navigation-inspector.tsx
  • resources/js/visual-editor/site-editor/navigation/navigation-section.tsx
  • resources/js/visual-editor/site-editor/navigation/use-navigation-editor.ts
  • resources/js/visual-editor/site-editor/site-editor-app.tsx
  • routes/api.php
  • src/Http/Controllers/EntitySearchController.php
  • src/Http/Controllers/MenuLocationsController.php
  • src/Http/Controllers/NavigationController.php
  • src/Http/Requests/StoreNavigationRequest.php
  • src/Http/Requests/UpdateNavigationRequest.php
  • src/Http/Resources/NavigationResource.php
  • src/Models/VisualEditorNavigation.php
  • src/Services/MenuLocationResolver.php
  • tests/Feature/VisualEditor/EntitySearchControllerTest.php
  • tests/Feature/VisualEditor/MenuLocationResolverTest.php
  • tests/Feature/VisualEditor/MenuLocationsControllerTest.php
  • tests/Feature/VisualEditor/NavigationControllerTest.php

Comment thread resources/js/visual-editor/site-editor/navigation/api-client.ts
Comment thread resources/js/visual-editor/site-editor/navigation/menu-tree.ts
Comment thread resources/js/visual-editor/site-editor/navigation/menu-tree.ts
Comment thread src/Services/MenuLocationResolver.php Outdated
- Promote the `location` column index to UNIQUE so single-occupant
  semantics are enforced at the database layer; wrap the
  release + save in a transaction so the constraint can't trap us
  in a half-applied state.
- Tighten `isMenuLocation` and `isSearchResult` to validate their
  nested `menu` and `url` fields (allow null / undefined for
  the lenient "no value" cases).
- Preserve the source `kind` / `type` block attributes through the
  editor's read → write cycle so custom post types (`kind:
  post-type, type: book`) and specific taxonomies round-trip
  cleanly. The IA-derived attributes still drive serialization
  whenever the user explicitly retypes a menu item via the link
  picker.
- Correct the `effectiveAssignments()` docblock — the method
  returns whatever `forLocation()` resolves (which may be a
  published-fallback nav, not always null).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
resources/js/visual-editor/site-editor/navigation/__tests__/menu-tree.test.ts (1)

225-229: Optional: deduplicate the repeated stripIds helper.

The same helper appears twice; extracting it once at file scope will reduce duplication and keep future updates consistent.

♻️ Suggested refactor
+function stripLocalIds(items: MenuItem[]): unknown[] {
+    return items.map(({ localId, children, ...rest }) => {
+        void localId;
+        return { ...rest, children: stripLocalIds(children) };
+    });
+}
@@
-        const stripIds = (items: MenuItem[]): unknown[] =>
-            items.map(({ localId, children, ...rest }) => {
-                void localId;
-                return { ...rest, children: stripIds(children) };
-            });
-
-        expect(stripIds(reparsed)).toEqual(stripIds(original));
+        expect(stripLocalIds(reparsed)).toEqual(stripLocalIds(original));
@@
-        const stripIds = (items: MenuItem[]): unknown[] =>
-            items.map(({ localId, children, ...rest }) => {
-                void localId;
-                return { ...rest, children: stripIds(children) };
-            });
-
-        expect(stripIds(reparsed)).toEqual(stripIds(tree));
+        expect(stripLocalIds(reparsed)).toEqual(stripLocalIds(tree));

Also applies to: 332-336

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@resources/js/visual-editor/site-editor/navigation/__tests__/menu-tree.test.ts`
around lines 225 - 229, The helper stripIds used in menu-tree.test.ts is
duplicated; extract a single shared stripIds function at the top/file scope and
have both test locations reference it to avoid repetition. Locate the existing
inline stripIds implementations (the function handling MenuItem, destructuring
localId and children) and remove the duplicates, keeping one exported/hoisted
function name stripIds(items: MenuItem[]) that returns the same shape, then
update the tests that previously declared their own versions to call the single
shared stripIds function.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@resources/js/visual-editor/site-editor/navigation/__tests__/menu-tree.test.ts`:
- Around line 225-229: The helper stripIds used in menu-tree.test.ts is
duplicated; extract a single shared stripIds function at the top/file scope and
have both test locations reference it to avoid repetition. Locate the existing
inline stripIds implementations (the function handling MenuItem, destructuring
localId and children) and remove the duplicates, keeping one exported/hoisted
function name stripIds(items: MenuItem[]) that returns the same shape, then
update the tests that previously declared their own versions to call the single
shared stripIds function.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 89952e14-0ebe-4ca1-afe2-bec18e6337e4

📥 Commits

Reviewing files that changed from the base of the PR and between 472a7db and 3865e81.

📒 Files selected for processing (8)
  • database/migrations/2026_04_24_000000_add_location_to_visual_editor_navigations_table.php
  • resources/js/visual-editor/site-editor/navigation/__tests__/link-picker.test.tsx
  • resources/js/visual-editor/site-editor/navigation/__tests__/menu-tree.test.ts
  • resources/js/visual-editor/site-editor/navigation/api-client.ts
  • resources/js/visual-editor/site-editor/navigation/link-picker.tsx
  • resources/js/visual-editor/site-editor/navigation/menu-tree.ts
  • src/Http/Controllers/NavigationController.php
  • src/Services/MenuLocationResolver.php
✅ Files skipped from review due to trivial changes (1)
  • resources/js/visual-editor/site-editor/navigation/menu-tree.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • database/migrations/2026_04_24_000000_add_location_to_visual_editor_navigations_table.php

@ViewFromTheBox
ViewFromTheBox merged commit a737f87 into release/1.0 Apr 25, 2026
1 check passed
@ViewFromTheBox
ViewFromTheBox deleted the feature/371-d4-nav-editor branch April 25, 2026 02:16
@github-project-automation github-project-automation Bot moved this from In Review to Done in ArtisanPack UI Overview Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

D4 · Navigation editor screen + core/navigation enablement

1 participant