diff --git a/docs/slideouts.md b/docs/slideouts.md new file mode 100644 index 00000000000..0c28340d494 --- /dev/null +++ b/docs/slideouts.md @@ -0,0 +1,437 @@ +# Slideouts + +Slideouts render a CP screen in a panel over the current page. A slideout request returns a normal +Inertia response, so **the same page component renders as either a full page or a slideout** — only +the shell around it differs. + +Screens that haven't been ported to a Vue page still work: they fall back to a built-in component +that draws the server-rendered HTML the response already carries. + +> This is the Vue/Inertia system. The legacy `Craft.CpScreenSlideout` (jQuery/Garnish, in +> `resources/js/modules/slideout/`) still exists and is unchanged — see +> [Coexisting with the legacy stack](#coexisting-with-the-legacy-stack). + +## Basic Usage + +Open any CP URL in a slideout: + +```vue + +``` + +`useSlideoutOpener()` returns `{open, closeAll}` and can be called outside `setup()`, so plain event +handlers and non-Vue code can use it too. + +The same functions are on `window.Craft`, which is handy from the console: + +```js +Craft.openSlideout('/admin/content/entries/news/5-hello'); +Craft.closeSlideout(id); +Craft.closeAllSlideouts(); +``` + +> These globals are registered when the Inertia CP boots, so they only exist on Inertia pages. On a +> page still served by the legacy stack (the dashboard, for example) `Craft.openSlideout` is +> undefined. + +## Opening + +### `openSlideout(href, options?)` + +Fetches `href` as an Inertia page and mounts it in a panel. Returns the `SlideoutInstance`. + +| Option | Type | Description | +| --- | --- | --- | +| `opener` | `HTMLElement \| null` | Element to refocus when the panel closes, and what the [stacking rules](#stacking-and-nesting) are resolved against. Defaults to whatever had focus when it opened. | +| `width` | `string` | Width for this panel, as any CSS **length**. Defaults to `--slideout-width`. | +| `onSaved` | `(result) => void` | Called when the screen saves. See [Telling the opener](#telling-the-opener). | + +Pass `opener` whenever you have it — focus restoration and nesting both depend on it. + +### Other exports + +```ts +import { + openSlideout, + closeSlideout, // (id: string) => void + closeAllSlideouts, + useSlideout, + useSlideoutOpener, +} from '@/common/slideouts'; +``` + +## Inside a slideout + +`useSlideout()` returns the panel the calling component is in, or `null` on a full page: + +```vue + +``` + +| Member | Description | +| --- | --- | +| `instance` | The `SlideoutInstance` (`id`, `href`, `props`, `loading`, `error`, …) | +| `close()` | Closes this panel, and anything nested inside it | +| `reload()` | Re-fetches the screen | +| `saved(result?)` | Reports a save to the opener; `false` means nobody was listening | + +To branch on context without caring about the panel itself: + +```ts +import {useIsSlideout} from '@/common/composables/screen'; + +if (useIsSlideout()) { + // … +} +``` + +## Making a screen work in a slideout + +**Any `CpScreenResponse` already works.** No controller changes are needed. + +```php +return (new CpScreenResponse()) + ->title($entryType->name) + ->contentTemplate('…') + ->action('entry-types/save'); +``` + +Requesting that screen with the slideout headers returns an Inertia page. If the response has no +`inertiaPage()`, the component is `cp/Screen`, which renders the response's HTML fragments +(`content`, `details`, `tabs`, `contentNotice`, `errorSummary`, `toolbar`) into the shell's slots. + +Porting a screen to a real Vue page is then just: + +```php +->inertiaPage('settings/entry-types/Edit', $viewModel) +``` + +That same component now serves the full page and the slideout. + +### How a request is routed + +`CpScreenResponse::toResponse()` treats a JSON-accepting request as a slideout — the convention +Craft 5 established — and picks the wire format from `X-Inertia`: + +| Caller | `Accept` | `X-Inertia` | Result | +| --- | --- | --- | --- | +| Inertia page visit | `text/html` | yes | Full page | +| Legacy `CpScreenSlideout` | `application/json` | no | Flat HTML payload (unchanged) | +| Vue slideout | `application/json` | yes | Inertia page object | + +Inertia's own client sends `Accept: text/html`, so an ordinary page visit can never be mistaken for +a slideout. + +Both formats are built from one pass over the screen, under a per-request input namespace keyed to +the `X-Craft-Container-Id` header — so two slideouts of the same screen don't collide on input +names. + +### Detecting a slideout server-side + +Unchanged from Craft 5 — the request accepts JSON: + +```php +if ($this->request->hasHeader('X-Craft-Container-Id')) { + // Rendering into a slideout. +} +``` + +## Saving + +Saving works in a slideout without navigating, on both kinds of screen. No controller changes are +needed: `RespondsWithFlash` already answers JSON whenever the request accepts it, so `asSuccess()` +and `asFailure()` do the right thing on their own. + +> Failures come back as **400**, not Laravel's usual 422 — `asJsonFailure()` picks it. Anything +> reading the status directly needs to expect that. + +### Vue pages + +Nothing to do. `useSettingsSave` detects the slideout and swaps its own submit strategy: + +| | Full page | Slideout | +| --- | --- | --- | +| Transport | navigating `form.submit()` | direct `axios` post | +| `redirect` in payload | sent | omitted — a panel closes instead | +| On success | follows the redirect | closes the panel, reloads the page behind | +| On failure | Inertia error bag | `form.setError()` from the 400 body | + +The elevated-session (423) retry, `transform`, and `elevatedFields` behave identically in both. + +Cmd/Ctrl+S is "save and continue editing" — it saves and **keeps the +panel open**. The Save button closes it. Internally that's the `redirect: false` flag, which is why +`SlideoutScreen` deliberately doesn't pass it. + +### Server-rendered screens + +Screens without an `inertiaPage()` have no Vue form — just markup — so the shell submits its own +`
`. `SlideoutScreen` shows a Save button whenever the response carries a `screen.action`, and +posts the panel's inputs with `submitScreenForm()`. + +Those inputs are already namespaced per panel (`ns[title]`, `ns[action]`, …), so the request carries +`X-Craft-Namespace` and `ExtractNamespace` un-prefixes them server-side back to what the controller +expects. That middleware runs *before* `HandleActionRequest`, so the un-namespaced `action` routes +correctly. + +Errors render through the same `ErrorSummary`, flattened to one message per field by +`firstMessages()` so both paths look identical. + +### Telling the opener + +By default a successful save reloads the whole page behind the panel. That's the only thing a +slideout opened from an arbitrary place can safely do — it has no idea what the screen affects. + +An opener that *does* know should say so: + +```ts +open(url, { + opener: button, + onSaved: () => router.reload({only: ['entryTypes']}), +}); +``` + +Registering `onSaved` also **takes over refreshing**: `saved()` returns `true`, and the panel skips +its blanket reload. `SlideoutButton` wires this to its `success` emit, so both of its call sites +refresh one prop instead of the page. + +The result carries `draft: true` for an autosaved draft rather than a finished save. The panel stays +open and keeps saving as the user types, so an opener that refreshes on it should debounce. + +> Save paths must call `saved()` **before** `close()` — closing drops the panel from the store, and +> its handler with it. `slideouts.test.ts` pins that. + +### The element editor + +The `elements/edit` screen is the exception: it doesn't submit itself. Drafts, autosaving, +provisional drafts, delta submission and tab error indicators all live in `Craft.ElementEditor`, so +the panel hands that screen over to it rather than reimplementing any of it — the same deal +`ElementEditorSlideout` strikes for the jQuery stack. + +`useElementEditor()` builds the editor and gives it the shell's regions: the panel ``, the +content and details columns, the header as a spinner host. Everything else is adapted through +callbacks — `updateTabs`/`getTabManager` onto a `Craft.Tabs` the bridge owns, submit results onto +the panel's own success and error handling. + +Two wrinkles worth knowing: + +- **Settings arrive as `screen.elementEditorSettings`,** not through `$(container).data()`. + `EditElementController` branches on `$request->inertia()` and calls `CpScreenResponse::screenData()` + instead of emitting the usual script. That script looks the container up by id, and it runs while + Vue still has the panel's subtree detached from the document, so the lookup finds nothing. Any + screen with the same problem can use `screenData()` the same way. +- **The editor is built on a `ready` signal, not on mount.** Fragments are appended asynchronously + (`appendHeadHtml()` resolves only once the screen's assets have loaded), and the editor snapshots + the form to detect changes — snapshotting an empty form would read every field as an edit. So + `cp/Screen` waits until *all* of its fragments have reported in, then calls the callback provided + under `ScreenContentReadyKey`. + +Once the editor is running it owns the screen: `SlideoutScreen` routes Save to it, hides the Save +button on a static screen (a revision), and lets it rename Cancel to "Close" once a provisional +draft exists. + +### Unsaved changes + +Discarding a panel with unsaved edits prompts first. That covers the close button, Cancel, +Esc, the shade — and **replacing**, which is the easy one to miss: opening a slideout +drops whatever is stacked above its opener, so double-clicking a second row on an index would +otherwise throw away an edited panel with no warning. Declining leaves everything untouched, and +`openSlideout()` resolves to `null`. + +Closing a panel with dirty children asks once for the whole subtree, not once per panel. + +How "dirty" is decided depends on the screen: + +| Screen | Signal | Notes | +| --- | --- | --- | +| Vue page | Inertia's `form.isDirty` | Precise — reverting an edit clears it | +| Server-rendered | any `input`/`change` in the panel | Conservative; `Craft.initUiElements()` rewrites inputs after load, so snapshot-diffing the markup would read as user edits | +| Element editor, autosaving | never dirty | Every edit is already persisted to a provisional draft, so there's nothing to lose — prompting anyway would make every element slideout ask on close | + +A close that follows a successful save passes `{force: true}` and never prompts — Inertia only +clears `isDirty` when a form's defaults are updated, so the panel still looks dirty at that moment. + +Register a check yourself with `setSlideoutDirtyCheck(id, () => boolean)`; `SlideoutScreen` does +this automatically and clears it when the panel closes. + +## Writing a page that works in both contexts + +Pages render inside a **shell**. `AppLayout` is a dispatcher that picks one: + +- `PageScreen` — the full CP shell (global nav, breadcrumbs, sidebar, footer) +- `SlideoutScreen` — the panel shell (title bar, tabs, body, save/cancel footer) + +Both implement the same contract in `resources/js/common/layouts/screens/types.ts`, so a page needs +no branching. Configure the shell the usual way: + +```vue + +``` + +In a slideout this configures the panel; on a full page it configures the layout. `` +teleports work the same way in both. + +### Slots a slideout has no room for + +`breadcrumbs`, `context-menu`, `title`, `title-badge`, `sidebar`, `subnav-actions` and `footer` are +still rendered as **hidden outlets** in a slideout, so a page written for a full page doesn't throw +or lose teleported content — that content is simply not shown. + +### Pages that render `` inline + +Rendering `` inside a slideout doesn't stack a second shell. The inner one becomes a +passthrough that forwards its props and `@save` up to the real shell, so `:title` and `:form` still +apply. + +### Reading page props + +`usePage()` always returns the **base** page. That's correct for shared props (flash, CSRF, the +`craft` bag), but a slideout reading `usePage().props.title` gets the title of the page *behind* it. + +Page-specific data arrives as component props, so `defineProps` is the normal answer. If you need +the current screen's props generically: + +```ts +import {useScreenPageProps} from '@/common/composables/screen'; + +const pageProps = useScreenPageProps(); // the slideout's own, or the base page's +``` + +## Stacking and nesting + +One rule: **opening a slideout closes whatever is stacked above the thing that opened it.** + +| Opened from | Result | +| --- | --- | +| The base page | Replaces any open panel | +| Inside panel 1 | Nests as panel 2 | +| Inside panel 1, while panel 2 is open | Replaces panel 2 | + +This is resolved from the `opener` element's position in the DOM, so it needs no configuration — +double-clicking a second row on an index swaps the panel, while a link inside a panel nests below +it. + +Closing a panel also closes anything nested inside it. Clicking the shade or pressing Esc +closes the **top** panel only, so each press peels one layer. + +Stacked panels spread across the space beside the newest one, so the stack never runs off the edge +however deep it goes — each panel just peeks out a little less. + +## Styling + +| Custom property | Default | Notes | +| --- | --- | --- | +| `--slideout-width` | `55vw` | Must be a **length**, not a percentage — the stack offset subtracts it from `100vw`. | +| `--slideout-shade-color` | `rgb(0 0 0 / 40%)` | The dimming behind the panel. | + +```css +:root { + --slideout-width: 40rem; +} +``` + +Or per panel: + +```ts +open(url, {width: '40rem'}); +``` + +The shell lays itself out against the **panel's** width via a container query, not the viewport's — +the details column stacks below the content under `44rem` and sits beside it above. On screens +narrower than `640px` the panel becomes a full-width sheet. + +## The element index + +Double-clicking a row on the entries index opens that element in a slideout. It's delegated on the +element container, so both table and cards views get it. + +Double-clicks on interactive elements — links, buttons, checkboxes, action menus, `craft-*` controls +— are ignored so those keep their own behaviour. Rows are skipped when the element isn't editable, +is trashed, or is inside an element picker. + +The behaviour lives in `useElementQuickEdit` and reads its metadata from the element chip's +`data-cp-url` / `data-editable` / `data-trashed` attributes. Those come from +`ElementHtml::elementChipHtml()` — the generic `chipHtml()` does **not** emit them, so an index +rendering chips the other way won't be double-clickable. + +### Keeping the row in sync + +Edits made in the slideout show up in the row behind it without a page reload. `useElementQuickEdit` +registers an `onSaved` handler that calls the index's own `refreshResults()` — the partial Inertia +reload of `data` / `pagination` / `badgeCounts` that bulk actions already use, minus the selection +clear, since editing one row shouldn't deselect anything. + +That covers both halves of an edit: + +| | When | Row shows | +| --- | --- | --- | +| Autosaved draft | debounced 600ms after each `afterSaveDraft` | the provisional title, with the *Edited* status badge | +| Full save | immediately | the applied values, badge gone | + +Provisional changes appear because `DisplayedInIndex::indexData()` already runs +`Drafts::loadProvisionalChanges()` over the elements it fetches — the refreshed rows carry them +without the index having to know drafts exist. + +The shell, the sources sidebar, scroll position and the table's selection all survive: only the +result props come back, and `rowSelection` is keyed by element id and lives outside the table. + +## Coexisting with the legacy stack + +The legacy `Craft.CpScreenSlideout` is untouched and still used by +`resources/js/common/components/SlideoutButton.vue`. Both stacks share `z-index: 100` and interleave +by DOM order. + +Two things to know if you touch this area: + +- **Don't reuse legacy class names.** The legacy stylesheet owns `.slideout-shade` and hides it with + `:not(.visible) { display: none }`. The Vue shade is `.cp-slideout-shade` for that reason. +- The legacy payload's key order is pinned by `tests/Feature/Http/Responses/CpScreenSlideoutTest.php`. + If that test goes red, the jQuery slideout stack is broken. + +## Not supported yet + +- **Live preview from a slideout.** `Craft.ElementEditor` builds its preview links off an action + button the Vue panel doesn't render yet, so the editor gets an empty one. +- **Deep linking.** Opening a slideout doesn't change the URL, and it can't be linked to. +- **Coordinated stacking with legacy slideouts.** A Vue panel opened over a + `Craft.CpScreenSlideout` will compete with it for the shade and Esc. + +## Files + +| Path | What it is | +| --- | --- | +| `resources/js/common/slideouts/` | Store, request layer, host, panel, `useSlideout` | +| `resources/js/common/layouts/AppLayout.vue` | The shell dispatcher | +| `resources/js/common/layouts/screens/` | `PageScreen`, `SlideoutScreen`, `PassthroughScreen`, the shared contract | +| `resources/js/common/composables/screen.ts` | Screen context, shell key, props store | +| `resources/js/pages/cp/Screen.vue` | Fallback page for screens without `inertiaPage()` | +| `resources/js/common/slideouts/submitScreenForm.ts` | Saving for server-rendered screens | +| `resources/js/common/slideouts/useElementEditor.ts` | `Craft.ElementEditor` bridge: drafts, autosave, tabs | +| `resources/js/modules/settings/composables/useSettingsSave.ts` | Saving for Vue pages, both contexts | +| `resources/js/modules/elements/composables/useElementQuickEdit.ts` | Index double-click | +| `src/Http/Responses/CpScreenResponse.php` | The slideout branch and its two wire formats | diff --git a/resources/js/bootstrap/cp.ts b/resources/js/bootstrap/cp.ts index b4a7124f90f..6556bc3bd33 100644 --- a/resources/js/bootstrap/cp.ts +++ b/resources/js/bootstrap/cp.ts @@ -21,6 +21,7 @@ import {setUrlDefaults} from '@/wayfinder'; import {inertiaPageRegistry, resolveInertiaPage} from './inertia-pages.js'; import AppLayout from '@/common/layouts/AppLayout.vue'; import {createCpComponentRegistry} from './components.js'; +import {registerSlideoutGlobals} from '@/common/slideouts'; import {configureIcons} from './icons.js'; import LocalFsSettings from '@/components/Filesystems/LocalFsSettings.vue'; @@ -161,6 +162,7 @@ const Cp = { handleNonInertiaRequests(); ensureLegacyNotificationContainer(); + registerSlideoutGlobals(); console.log('Calling booted callbacks', bootedCallbacks); bootedCallbacks.forEach((callback) => callback(this)); diff --git a/resources/js/common/components/HtmlFragmentRenderer.vue b/resources/js/common/components/HtmlFragmentRenderer.vue index 36bc7358ca2..a1481319e9a 100644 --- a/resources/js/common/components/HtmlFragmentRenderer.vue +++ b/resources/js/common/components/HtmlFragmentRenderer.vue @@ -19,6 +19,11 @@ } ); + const emit = defineEmits<{ + /** The fragment — assets included — is in the document. */ + (e: 'ready', element: HTMLElement): void; + }>(); + const container = ref(null); const disposers: AppendHtmlDisposer[] = []; let lastKey = ''; @@ -105,6 +110,8 @@ if (html) { (window as any).Craft?.initUiElements?.(element); } + + emit('ready', element); }, {immediate: true} ); diff --git a/resources/js/common/components/LayoutSlot.vue b/resources/js/common/components/LayoutSlot.vue index 21513a5aff4..1348f4d3cf7 100644 --- a/resources/js/common/components/LayoutSlot.vue +++ b/resources/js/common/components/LayoutSlot.vue @@ -1,29 +1,45 @@ diff --git a/resources/js/common/components/LayoutSlotOutlet.vue b/resources/js/common/components/LayoutSlotOutlet.vue index 1e9d0bbaa63..1baf9534f81 100644 --- a/resources/js/common/components/LayoutSlotOutlet.vue +++ b/resources/js/common/components/LayoutSlotOutlet.vue @@ -1,14 +1,16 @@ diff --git a/resources/js/modules/elements/composables/useElementIndexPage.ts b/resources/js/modules/elements/composables/useElementIndexPage.ts index d24896c356a..22c30c0a3df 100644 --- a/resources/js/modules/elements/composables/useElementIndexPage.ts +++ b/resources/js/modules/elements/composables/useElementIndexPage.ts @@ -134,17 +134,28 @@ export function useElementIndexPage(options: UseElementIndexPageOptions) { // element id → selected) or via `elementTable.getSelectedRowModel()`. const rowSelection = ref({}); - // After a bulk action succeeds, refresh the server-rendered list + counts the - // same way the view-mode/filter composables do (a partial Inertia reload that - // only re-pulls the index props), then clear any lingering selection. The - // table also clears its own selection optimistically when the action fires. - function onActionPerformed() { - rowSelection.value = {}; + /** + * Re-pull the server-rendered list and counts, the same way the + * view-mode/filter composables do — a partial Inertia reload of just the + * index props. + * + * Everything else stays put: the shell, the sources sidebar, scroll position, + * and the selection (`rowSelection` lives out here, keyed by element id). + */ + function refreshResults() { router.reload({ only: ['data', 'pagination', 'badgeCounts'], }); } + // After a bulk action succeeds, refresh and clear any lingering selection — + // the rows it applied to may not even be in the list any more. The table also + // clears its own selection optimistically when the action fires. + function onActionPerformed() { + rowSelection.value = {}; + refreshResults(); + } + function createCustomizeSourcesModal() { // The modal was written for the legacy BaseElementIndex instance, but it // only reads a few things off it: the element type (to load/save settings), @@ -215,7 +226,7 @@ export function useElementIndexPage(options: UseElementIndexPageOptions) { // Clear it on teardown, but only if it's still the current one — during an SPA // page swap the next page may register before this one unmounts. const {table: activeTable, register} = useElementIndexTable(); - register({table: elementTable, onActionPerformed}); + register({table: elementTable, onActionPerformed, refreshResults}); onScopeDispose(() => { if (activeTable.value === elementTable) { register(null); @@ -237,6 +248,7 @@ export function useElementIndexPage(options: UseElementIndexPageOptions) { loading, visibleViewModes, rowSelection, + refreshResults, onActionPerformed, createCustomizeSourcesModal, }; diff --git a/resources/js/modules/elements/composables/useElementIndexTable.ts b/resources/js/modules/elements/composables/useElementIndexTable.ts index b7178a2bc2a..f92d977d4b7 100644 --- a/resources/js/modules/elements/composables/useElementIndexTable.ts +++ b/resources/js/modules/elements/composables/useElementIndexTable.ts @@ -6,6 +6,8 @@ interface ElementIndexHandle { table: Table; /** The index's post-action refresh: clears selection + partial reload. */ onActionPerformed: () => void; + /** Re-pull just the results, leaving selection and scroll alone. */ + refreshResults: () => void; } // The active element index. There's a single one per CP page, so a module-scoped @@ -32,5 +34,10 @@ export function useElementIndexTable() { active.value?.onActionPerformed(); } - return {table, onActionPerformed, register}; + /** Re-pull the active index's results, if one is registered. */ + function refreshResults() { + active.value?.refreshResults(); + } + + return {table, onActionPerformed, refreshResults, register}; } diff --git a/resources/js/modules/elements/composables/useElementQuickEdit.test.ts b/resources/js/modules/elements/composables/useElementQuickEdit.test.ts new file mode 100644 index 00000000000..dc59aad998b --- /dev/null +++ b/resources/js/modules/elements/composables/useElementQuickEdit.test.ts @@ -0,0 +1,249 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const openSlideout = vi.hoisted(() => vi.fn()); +const refreshResults = vi.hoisted(() => vi.fn()); + +vi.mock('@/common/slideouts', () => ({openSlideout})); +vi.mock('@/modules/elements/composables/useElementIndexTable', () => ({ + useElementIndexTable: () => ({refreshResults}), +})); + +const {useElementQuickEdit} = await import('./useElementQuickEdit'); + +const {onDblClick} = useElementQuickEdit(); + +beforeEach(() => { + openSlideout.mockReset(); + refreshResults.mockReset(); +}); +afterEach(() => { + document.body.innerHTML = ''; +}); + +const CP_URL = '/admin/entries/news/5-hello'; + +/** + * A table row, mirroring what `ContentIndexViewModel::tableRows()` emits: the + * element's metadata on the title chip, wrapped in a link to its edit page. + */ +function renderRow(attributes: Record = {}) { + const data = {'data-editable': '', 'data-cp-url': CP_URL, ...attributes}; + + const table = document.createElement('table'); + table.innerHTML = ` + + + + + + + + `${key}="${value}"`) + .join(' ')}> + Hello + + + + Today + + + + `; + document.body.appendChild(table); + + return { + row: table.querySelector('tr')!, + chip: table.querySelector('.element')!, + link: table.querySelector('a')!, + checkbox: table.querySelector('craft-checkbox')!, + actionMenu: table.querySelector('craft-action-menu')!, + postDate: table.querySelector('.cp-table-cell--postDate')!, + }; +} + +/** + * A card, which puts the `element` class on the `
  • ` and the metadata on the + * `` inside it. + */ +function renderCard() { + const list = document.createElement('ul'); + list.innerHTML = ` +
  • + +
    +
    Hello
    +
    +
  • + `; + document.body.appendChild(list); + + return { + card: list.querySelector('li')!, + body: list.querySelector('.card-body')!, + checkbox: list.querySelector('craft-checkbox')!, + }; +} + +function dblclick(target: Element): MouseEvent { + const event = new MouseEvent('dblclick', {bubbles: true, cancelable: true}); + Object.defineProperty(event, 'target', {value: target}); + onDblClick(event); + + return event; +} + +describe('useElementQuickEdit', () => { + it('opens the element when a row is double-clicked', () => { + const {postDate} = renderRow(); + + const event = dblclick(postDate); + + expect(openSlideout).toHaveBeenCalledWith(CP_URL, expect.anything()); + expect(event.defaultPrevented).toBe(true); + }); + + it('opens from a double-click on the row itself', () => { + const {row} = renderRow(); + + dblclick(row); + + expect(openSlideout).toHaveBeenCalledWith(CP_URL, expect.anything()); + }); + + it('opens the element when a card is double-clicked', () => { + const {body} = renderCard(); + + dblclick(body); + + // Cards keep the metadata on the inner ``, not the `.element` + // wrapper, so this only works if both shapes are handled. + expect(openSlideout).toHaveBeenCalledWith(CP_URL, expect.anything()); + }); + + describe('leaves interactive controls alone', () => { + it.each([ + ['the title link', (r: ReturnType) => r.link], + ['the chip inside the link', (r: ReturnType) => r.chip], + ['the select checkbox', (r: ReturnType) => r.checkbox], + ['the action menu', (r: ReturnType) => r.actionMenu], + ])('ignores a double-click on %s', (_label, pick) => { + const row = renderRow(); + + const event = dblclick(pick(row)); + + expect(openSlideout).not.toHaveBeenCalled(); + // The control's own behaviour has to survive untouched. + expect(event.defaultPrevented).toBe(false); + }); + + it('ignores a double-click on a checkbox inside a card', () => { + const {checkbox} = renderCard(); + + dblclick(checkbox); + + expect(openSlideout).not.toHaveBeenCalled(); + }); + + it('ignores content nested inside a link', () => { + const {link} = renderRow(); + + dblclick(link.querySelector('.label-link')!); + + expect(openSlideout).not.toHaveBeenCalled(); + }); + }); + + it('ignores a double-click on a non-editable element', () => { + const {chip, postDate} = renderRow(); + chip.removeAttribute('data-editable'); + + dblclick(postDate); + + expect(openSlideout).not.toHaveBeenCalled(); + }); + + it('ignores a double-click on a trashed element', () => { + const {postDate} = renderRow({'data-trashed': ''}); + + dblclick(postDate); + + expect(openSlideout).not.toHaveBeenCalled(); + }); + + it('ignores an element with no edit url', () => { + const {chip, postDate} = renderRow(); + chip.removeAttribute('data-cp-url'); + + dblclick(postDate); + + expect(openSlideout).not.toHaveBeenCalled(); + }); + + it('ignores rows inside an element picker', () => { + const {row, postDate} = renderRow(); + const table = row.closest('table')!; + const picker = document.createElement('div'); + picker.className = 'elementselect'; + table.replaceWith(picker); + picker.appendChild(table); + + dblclick(postDate); + + // Rows in a picker are a selection UI, not an index. + expect(openSlideout).not.toHaveBeenCalled(); + }); + + it('ignores a double-click outside any row', () => { + renderRow(); + + dblclick(document.body); + + expect(openSlideout).not.toHaveBeenCalled(); + }); +}); + +/** + * A save in the slideout has to show up in the row behind it. A full + * `router.reload()` would work but throws away scroll position and the table's + * selection, so the index asks for just the results. + */ +describe('refreshing the index after a save', () => { + /** The `onSaved` handler `useElementQuickEdit` registered with the panel. */ + function openedWith(): (result: {draft?: boolean}) => void { + const {postDate} = renderRow(); + dblclick(postDate); + + return openSlideout.mock.calls[0]![1].onSaved; + } + + it('pulls fresh rows without leaving the page', () => { + openedWith()({}); + + // The index's own partial reload — not a full visit, and not the + // bulk-action one, which would clear the selection too. + expect(refreshResults).toHaveBeenCalled(); + }); + + it('debounces autosaved drafts into one refresh', async () => { + vi.useFakeTimers(); + + try { + const onSaved = openedWith(); + + onSaved({draft: true}); + onSaved({draft: true}); + onSaved({draft: true}); + + // Typing shouldn't cost a request per keystroke-batch… + expect(refreshResults).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(600); + + // …but the last one always lands. + expect(refreshResults).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/resources/js/modules/elements/composables/useElementQuickEdit.ts b/resources/js/modules/elements/composables/useElementQuickEdit.ts new file mode 100644 index 00000000000..7e294b67217 --- /dev/null +++ b/resources/js/modules/elements/composables/useElementQuickEdit.ts @@ -0,0 +1,140 @@ +import {useDebounceFn} from '@vueuse/core'; +import {openSlideout, type SlideoutSaveResult} from '@/common/slideouts'; +import {useElementIndexTable} from '@/modules/elements/composables/useElementIndexTable'; + +/** + * Anything in a row that owns its own click. Double-clicking one of these + * should do whatever that control does — follow the link, toggle the checkbox, + * open the menu — not open an editor behind it. + * + * Craft 5 checked `a[href], button, [role=button], .move`; the rest are the CP + * web components that appear in a Vue index row. + */ +const INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'textarea', + 'label', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[contenteditable="true"]', + '.move', + 'craft-button', + 'craft-checkbox', + 'craft-action-menu', + 'craft-reorder-button', +].join(', '); + +/** + * Double-click a row in an element index to edit it in a slideout. + * + * Bound once on the element container and delegated, the way Craft 5's + * `BaseElementIndexView` does it — so the table and cards views both get it + * without either having to know about it. + */ +export function useElementQuickEdit() { + // The active index's partial reload — the same one a bulk action triggers, + // minus clearing the selection. Editing one row shouldn't deselect anything. + const {refreshResults} = useElementIndexTable(); + + /** + * Drafts autosave as the user types, and each one is a chance for the row to + * drift out of date. Trailing-edge only: mid-word rows aren't worth a + * request, and the last one always lands. + */ + const refreshSoon = useDebounceFn(refreshResults, 600); + + function onSaved(result: SlideoutSaveResult): void { + if (result.draft) { + void refreshSoon(); + + return; + } + + refreshResults(); + } + + /** + * The row (table) or card (cards view) an event landed in. + * + * Cards put the `element` class on the `
  • ` and the element's `data-` + * metadata on the `` inside it, so the class is the only thing + * common to both shapes. + */ + function rowFrom(target: Element): Element | null { + return target.closest('tr') ?? target.closest('.element'); + } + + /** + * The node carrying the element's metadata. + * + * In a table that's the title chip; in cards it's the ``. Taking + * the first match in document order picks the row's own element rather than + * one referenced by a later column (an author chip, say) — the same thing + * Craft 5's `.find('.element:first')` did. + */ + function elementIn(row: Element): HTMLElement | null { + const element = row.matches('[data-cp-url]') + ? (row as HTMLElement) + : row.querySelector('[data-cp-url]'); + + if (!element) { + return null; + } + + // `data-editable` / `data-trashed` are omitted entirely when false, so + // presence is the test — same as the legacy `Garnish.hasAttr` check. + if ( + !element.hasAttribute('data-editable') || + element.hasAttribute('data-trashed') + ) { + return null; + } + + // Inside an element picker the rows are a selection UI, not an index. + return element.closest('.elementselect') ? null : element; + } + + function onDblClick(event: MouseEvent): void { + const target = event.target; + + if (!(target instanceof Element)) { + return; + } + + const row = rowFrom(target); + + if (!row) { + return; + } + + // Scoped to the row so a control somewhere else on the page can't suppress + // a legitimate double-click. + const control = target.closest(INTERACTIVE_SELECTOR); + + if (control && row.contains(control)) { + return; + } + + const element = elementIn(row); + + if (!element) { + return; + } + + event.preventDefault(); + + // Two fast clicks leave a text selection behind. + window.getSelection()?.removeAllRanges(); + + void openSlideout(element.dataset.cpUrl!, { + opener: row instanceof HTMLElement ? row : null, + onSaved, + }); + } + + return {onDblClick}; +} diff --git a/resources/js/modules/settings/composables/useSettingsSave.test.ts b/resources/js/modules/settings/composables/useSettingsSave.test.ts new file mode 100644 index 00000000000..05d5b19dffe --- /dev/null +++ b/resources/js/modules/settings/composables/useSettingsSave.test.ts @@ -0,0 +1,224 @@ +import {effectScope} from 'vue'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const axiosRequest = vi.hoisted(() => vi.fn()); +const routerReload = vi.hoisted(() => vi.fn()); +const slideout = vi.hoisted(() => ({ + value: null as null | { + instance: {containerId: string}; + close: () => void; + saved: (result?: unknown) => boolean; + }, +})); +const elevated = vi.hoisted(() => ({require: vi.fn()})); + +vi.mock('axios', () => ({ + default: {request: axiosRequest}, +})); + +vi.mock('@inertiajs/vue3', () => ({ + router: {reload: routerReload}, + usePage: () => ({props: {}}), +})); + +vi.mock('@/common/slideouts/useSlideout', () => ({ + useSlideout: () => slideout.value, +})); + +vi.mock('@/modules/auth/elevated-session', () => ({ + elevatedSessionManager: elevated, +})); + +const {useSettingsSave} = await import('./useSettingsSave'); + +/** A stand-in for Inertia's `useForm` result, with just what the composable drives. */ +function makeForm(data: Record = {name: 'Widgets'}) { + return { + processing: false, + isDirty: false, + errors: {} as Record, + data: () => data, + clearErrors: vi.fn(function (this: any) { + this.errors = {}; + return this; + }), + setError: vi.fn(function (this: any, errors: Record) { + this.errors = errors; + return this; + }), + transform: vi.fn(function (this: any) { + return this; + }), + submit: vi.fn(), + }; +} + +const action = () => ({url: '/admin/entry-types/save', method: 'post'}); + +let scope: ReturnType; + +function run(fn: () => T): T { + scope = effectScope(); + + return scope.run(fn)!; +} + +beforeEach(() => { + axiosRequest.mockReset().mockResolvedValue({data: {message: 'Saved.'}}); + routerReload.mockReset(); + elevated.require.mockReset().mockResolvedValue(true); + slideout.value = { + instance: {containerId: 'slideout-1'}, + close: vi.fn(), + // Nobody listening, so the panel falls back to reloading the page behind. + saved: vi.fn(() => false), + }; +}); + +afterEach(() => scope?.stop()); + +describe('useSettingsSave in a slideout', () => { + it('posts directly instead of making an Inertia visit', async () => { + const form = makeForm(); + const {save} = run(() => useSettingsSave(form as any, action)); + + save(); + await vi.waitFor(() => expect(axiosRequest).toHaveBeenCalled()); + + // An Inertia visit would replace the page behind the panel. + expect(form.submit).not.toHaveBeenCalled(); + + const req = axiosRequest.mock.calls[0]![0]; + expect(req.url).toBe('/admin/entry-types/save'); + expect(req.method).toBe('post'); + expect(req.headers['X-Craft-Container-Id']).toBe('slideout-1'); + }); + + it('never sends a redirect — a slideout closes instead of navigating', async () => { + const {save} = run(() => useSettingsSave(makeForm() as any, action)); + + save(); + await vi.waitFor(() => expect(axiosRequest).toHaveBeenCalled()); + + expect(axiosRequest.mock.calls[0]![0].data).not.toHaveProperty('redirect'); + }); + + it('applies the transform to the payload', async () => { + const form = makeForm({name: 'Widgets'}); + const {save} = run(() => + useSettingsSave(form as any, action, { + transform: (data: any) => ({...data, fieldLayout: '[]'}), + }) + ); + + save(); + await vi.waitFor(() => expect(axiosRequest).toHaveBeenCalled()); + + expect(axiosRequest.mock.calls[0]![0].data).toMatchObject({ + name: 'Widgets', + fieldLayout: '[]', + }); + }); + + it('closes the panel and refreshes the page behind on success', async () => { + const close = vi.fn(); + slideout.value = { + instance: {containerId: 'slideout-1'}, + close, + saved: vi.fn(() => false), + }; + const form = makeForm(); + const {save} = run(() => useSettingsSave(form as any, action)); + + save(); + await vi.waitFor(() => expect(routerReload).toHaveBeenCalled()); + + expect(close).toHaveBeenCalled(); + expect(form.processing).toBe(false); + }); + + it('keeps the panel open for save-and-continue', async () => { + const close = vi.fn(); + slideout.value = { + instance: {containerId: 'slideout-1'}, + close, + saved: vi.fn(() => false), + }; + const {save} = run(() => useSettingsSave(makeForm() as any, action)); + + save({redirect: false}); + await vi.waitFor(() => expect(routerReload).toHaveBeenCalled()); + + expect(close).not.toHaveBeenCalled(); + }); + + /** + * An opener that registered `onSaved` refreshes itself, and knows better than + * the panel does what needs refreshing — so the blanket reload is skipped. + */ + it('leaves refreshing to the opener when it handles the save', async () => { + const saved = vi.fn((_result?: unknown) => true); + const close = vi.fn(); + slideout.value = {instance: {containerId: 'slideout-1'}, close, saved}; + + const {save} = run(() => useSettingsSave(makeForm() as any, action)); + + save(); + await vi.waitFor(() => expect(saved).toHaveBeenCalled()); + + expect(saved.mock.calls[0]![0]).toEqual({data: {message: 'Saved.'}}); + expect(routerReload).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + }); + + it('maps a 400 validation failure onto the form', async () => { + // `asJsonFailure()` answers 400 — not Laravel's usual 422 — and sends + // `{field: [message, …]}`, which has to flatten to one message per field + // to match the full-page path. + axiosRequest.mockRejectedValue({ + response: {status: 400, data: {errors: {name: ['Name is required.']}}}, + }); + + const form = makeForm(); + const {save} = run(() => useSettingsSave(form as any, action)); + + save(); + await vi.waitFor(() => expect(form.setError).toHaveBeenCalled()); + + expect(form.errors).toEqual({name: 'Name is required.'}); + expect(form.processing).toBe(false); + // A failed save must not close the panel or discard the user's input. + expect(routerReload).not.toHaveBeenCalled(); + }); + + it('retries once behind an elevated session on 423', async () => { + axiosRequest + .mockRejectedValueOnce({response: {status: 423}}) + .mockResolvedValueOnce({data: {message: 'Saved.'}}); + + const {save} = run(() => + useSettingsSave(makeForm() as any, action, {elevatedFields: '*'}) + ); + + save(); + await vi.waitFor(() => expect(axiosRequest).toHaveBeenCalledTimes(2)); + + expect(elevated.require).toHaveBeenCalled(); + }); +}); + +describe('useSettingsSave on a full page', () => { + beforeEach(() => { + slideout.value = null; + }); + + it('still makes an ordinary Inertia visit', async () => { + const form = makeForm(); + const {save} = run(() => useSettingsSave(form as any, action)); + + save(); + + expect(form.submit).toHaveBeenCalled(); + expect(axiosRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/resources/js/modules/settings/composables/useSettingsSave.ts b/resources/js/modules/settings/composables/useSettingsSave.ts index b4df0d835a1..332afcced51 100644 --- a/resources/js/modules/settings/composables/useSettingsSave.ts +++ b/resources/js/modules/settings/composables/useSettingsSave.ts @@ -1,8 +1,11 @@ import {useEventListener} from '@vueuse/core'; -import {type InertiaForm, usePage} from '@inertiajs/vue3'; +import {type InertiaForm, router, usePage} from '@inertiajs/vue3'; import {computed} from 'vue'; +import axios from 'axios'; import type {FormSaveOptions} from '@/common/types'; import {elevatedSessionManager} from '@/modules/auth/elevated-session'; +import {useSlideout} from '@/common/slideouts/useSlideout'; +import {firstMessages} from '@/common/slideouts/errors'; interface PasswordConfirmationOptions { required: (data: T) => boolean; @@ -32,6 +35,10 @@ export function useSettingsSave>( }>(); const redirectUrl = computed(() => page.props.redirectUrl); + // Non-null when this screen is rendering inside a slideout, in which case + // saving must not navigate — see `submitInSlideout()`. + const slideout = useSlideout(); + // `elevatedFields` is sugar that generates a `passwordConfirmation` config, so // the proactive check and the 423 retry below both flow through one path. An // explicit `passwordConfirmation` always wins. @@ -63,7 +70,99 @@ export function useSettingsSave>( replace: true, }; + /** + * Save from inside a slideout, without navigating. + * + * `form.submit()` is an Inertia visit, which would replace the page *behind* + * the panel. The controllers already answer JSON whenever the request + * accepts it (`RespondsWithFlash`), so this posts directly and drives the + * form state by hand. Note the failure status is **400**, not Laravel's + * usual 422 — `asJsonFailure()` picks it. + */ + async function submitInSlideout(retried = false): Promise { + const route = action(); + + form.clearErrors(); + form.processing = true; + + try { + const response = await axios.request({ + url: typeof route === 'string' ? route : route.url, + method: typeof route === 'string' ? 'post' : (route.method ?? 'post'), + // No `redirect`: a slideout closes rather than navigating anywhere. + data: { + ...(options.transform?.(form.data()) ?? form.data()), + ...extraData, + }, + headers: { + 'X-Craft-Container-Id': slideout!.instance.containerId, + }, + }); + + form.processing = false; + + // An opener that registered `onSaved` refreshes itself, and knows + // better than we do what actually needs refreshing. Before the close: + // closing drops the panel from the store, taking its handler with it. + const handled = slideout!.saved({data: response.data}); + + // `redirect: false` is "save and continue editing" (the cmd+S path), + // which keeps the panel open. `force` because the form can still read + // dirty right after a save — Inertia only clears that when its + // defaults are updated, which the page behind does on reload. + if (redirect !== false) { + slideout!.close({force: true}); + } + + if (handled) { + return; + } + + // Otherwise: the controller flashes the success message to the session + // even on its JSON branch, so refreshing the page behind both surfaces + // that message and picks up whatever was just saved. `reload()` + // preserves scroll and state inherently. + router.reload(); + } catch (error: any) { + form.processing = false; + + const status = error?.response?.status; + + if (passwordConfirmation && status === 423 && !retried) { + elevatedSessionManager + .require({ + force: true, + minimumRemainingSeconds: + passwordConfirmation.minimumRemainingSeconds, + }) + .then((confirmed) => { + if (confirmed) { + void submitInSlideout(true); + } + }); + + return; + } + + const errors = error?.response?.data?.errors; + + if (errors) { + form.setError(firstMessages(errors) as any); + + return; + } + + throw error; + } + } + function submit(retried = false) { + if (slideout) { + void submitInSlideout(retried); + + return; + } + form .clearErrors() .transform((data: T) => { diff --git a/resources/js/pages/cp/Screen.vue b/resources/js/pages/cp/Screen.vue new file mode 100644 index 00000000000..e3a734081b6 --- /dev/null +++ b/resources/js/pages/cp/Screen.vue @@ -0,0 +1,116 @@ + + + diff --git a/src/Http/Controllers/Elements/EditElementController.php b/src/Http/Controllers/Elements/EditElementController.php index 7ed73709c09..20201193144 100644 --- a/src/Http/Controllers/Elements/EditElementController.php +++ b/src/Http/Controllers/Elements/EditElementController.php @@ -717,7 +717,12 @@ private function prepareEditor( $settings = $jsSettingsFn($form); - if ($this->isSlideout()) { + if ($this->request->inertia()) { + // The Vue slideout builds its own `Craft.ElementEditor`, but can't + // receive settings the jQuery way: that script looks the container + // up by id and runs before Vue has put the panel in the document. + $response->screenData(['elementEditorSettings' => $settings]); + } elseif ($this->isSlideout()) { HtmlStack::jsWithVars(fn ($settings) => << Extra data merged into the Inertia `screen` + * prop, for screens whose client-side behavior + * needs configuring. + * + * @see screenData() + */ + private array $screenData = []; + /** * @var array Custom attributes to add to the `
    ` tag. * @@ -735,22 +744,67 @@ public function errorSummaryTemplate(string $template, array $variables = []): s ); } + /** + * Merge extra data into the Inertia `screen` prop. + * + * For screens that hand configuration to client-side code. The Twig/jQuery + * paths pass such config by injecting a script that looks the container up + * by id — which races Vue's mount, since the panel's subtree isn't in the + * document yet when that script runs. Props arrive with the page instead. + * + * @param array $data + */ + public function screenData(array $data): self + { + $this->screenData = [...$this->screenData, ...$data]; + + return $this; + } + public function toResponse($request): Response { if ($request->wantsJson()) { - return $this->jsonResponse($request); + return $this->slideoutResponse($request); } return $this->response($request); } - private function jsonResponse(Request $request): JsonResponse + /** + * Render the screen into a slideout. + * + * Two clients ask for this, and they want different wire formats. The Vue + * client sends `X-Inertia` and gets an Inertia page it can mount as a + * component; the legacy jQuery `CpScreenSlideout` gets the flat payload of + * server-rendered HTML it has always got. Both are built from one pass over + * the screen, so a screen behaves identically whichever one asks. + * + * A regular Inertia page visit never lands here: Inertia's own client sends + * `Accept: text/html`, so `wantsJson()` is false for it. + */ + private function slideoutResponse(Request $request): Response + { + $parts = $this->prepareSlideout($request); + + return $request->inertia() + ? $this->slideoutInertiaResponse($request, $parts) + : $this->slideoutJsonResponse($parts); + } + + /** + * Resolve the screen's parts under a per-request input namespace. + * + * The namespace keeps two slideouts of the same screen from colliding on + * input names, so it has to wrap `prepareScreen` as well as the rendering. + * + * @return array + */ + private function prepareSlideout(Request $request): array { $namespace = Str::random(10); + $containerId = $request->header('X-Craft-Container-Id'); if ($this->prepareScreen) { - $containerId = $request->header('X-Craft-Container-Id'); - abort_unless((bool) $containerId, 400, 'Request missing the X-Craft-Container-Id header.'); InputNamespace::set($namespace); @@ -784,23 +838,45 @@ private function jsonResponse(Request $request): JsonResponse $sidebar = $this->metaSidebarHtml ? InputNamespace::namespaceInputs($this->metaSidebarHtml, $namespace) : null; $errorSummary = $this->errorSummary ? InputNamespace::namespaceInputs($this->errorSummary, $namespace) : null; - return new JsonResponse([ - 'editUrl' => $this->editUrl ? Url::cpUrl($this->editUrl) : null, + // Read after everything above: rendering the screen is what pushes onto + // the HTML stack and registers delta names. + return [ 'namespace' => $namespace, - 'title' => $this->title, + 'containerId' => $containerId, + 'extraToolbarItems' => $extraToolbarItems, 'notice' => $notice, 'tabs' => $tabs, + 'content' => $content, + 'sidebar' => $sidebar, + 'errorSummary' => $errorSummary, + 'actionMenu' => $this->actionMenu(withDestructive: false, config: [ + 'withButton' => false, + ], namespace: $namespace), + ]; + } + + /** + * The legacy `Craft.CpScreenSlideout` payload. + * + * @param array $parts + */ + private function slideoutJsonResponse(array $parts): JsonResponse + { + return new JsonResponse([ + 'editUrl' => $this->editUrl ? Url::cpUrl($this->editUrl) : null, + 'namespace' => $parts['namespace'], + 'title' => $this->title, + 'notice' => $parts['notice'], + 'tabs' => $parts['tabs'], 'bodyClass' => $this->slideoutBodyClass, 'formAttributes' => $this->formAttributes, 'action' => $this->action, - 'extraToolbarItems' => $extraToolbarItems, + 'extraToolbarItems' => $parts['extraToolbarItems'], 'submitButtonLabel' => $this->submitButtonLabel, - 'actionMenu' => $this->actionMenu(withDestructive: false, config: [ - 'withButton' => false, - ], namespace: $namespace), - 'content' => $content, - 'sidebar' => $sidebar, - 'errorSummary' => $errorSummary, + 'actionMenu' => $parts['actionMenu'], + 'content' => $parts['content'], + 'sidebar' => $parts['sidebar'], + 'errorSummary' => $parts['errorSummary'], 'headHtml' => HtmlStack::headHtml(), 'bodyHtml' => HtmlStack::bodyHtml(), 'deltaNames' => DeltaRegistry::getNames(), @@ -808,6 +884,63 @@ private function jsonResponse(Request $request): JsonResponse ]); } + /** + * The Inertia slideout payload. + * + * Screens that haven't been ported to a Vue page still render here — they + * fall back to the `cp/Screen` component, which draws the same HTML + * fragments the legacy payload carries. + * + * @param array $parts + */ + private function slideoutInertiaResponse(Request $request, array $parts): Response + { + return Inertia::render($this->inertiaPage ?? 'cp/Screen', $this->inertiaProps) + ->with($this->screenProps('slideout', [ + 'containerId' => $parts['containerId'], + 'namespace' => $parts['namespace'], + 'editUrl' => $this->editUrl ? Url::cpUrl($this->editUrl) : null, + 'bodyClass' => $this->slideoutBodyClass, + 'action' => $this->action, + 'formAttributes' => $this->formAttributes, + 'deltaNames' => DeltaRegistry::getNames(), + 'initialDeltaValues' => DeltaRegistry::getInitialValues(), + ])) + ->with([ + 'title' => $this->title, + 'submitButtonLabel' => $this->submitButtonLabel, + 'actionMenu' => $parts['actionMenu'], + 'toolbar' => $parts['extraToolbarItems'], + // Populated for every screen; `cp/Screen` renders them, and a + // Vue page ignores them. + 'tabs' => $parts['tabs'], + 'contentNotice' => $parts['notice'], + 'content' => $parts['content'], + 'details' => $parts['sidebar'], + 'errorSummary' => $parts['errorSummary'], + ]) + ->toResponse($request); + } + + /** + * Tells the client which context the screen is rendering in. + * + * `headHtml`/`bodyHtml` ride along as props because the blade root view — + * where `HandleInertiaRequests` normally injects them — isn't rendered for + * an XHR Inertia response. + * + * @param array $extra + * @return array + */ + private function screenProps(string $mode, array $extra = []): array + { + return [ + 'screen' => ['mode' => $mode] + $extra + $this->screenData, + 'headHtml' => HtmlStack::headHtml(), + 'bodyHtml' => HtmlStack::bodyHtml(), + ]; + } + private function response(Request $request): Response { $isForm = (bool) $this->action; @@ -913,6 +1046,7 @@ private function response(Request $request): Response return Inertia::render($this->inertiaPage, $this->inertiaProps) ->with($templateProps) + ->with($this->screenProps('page')) ->toResponse($request); } diff --git a/src/Http/ViewModels/ContentIndexViewModel.php b/src/Http/ViewModels/ContentIndexViewModel.php index 7c27f2c5716..228f0c6a7de 100644 --- a/src/Http/ViewModels/ContentIndexViewModel.php +++ b/src/Http/ViewModels/ContentIndexViewModel.php @@ -689,7 +689,12 @@ private function titleCellHtml(ElementInterface $element, ElementHtml $elementHt return $chip; } - return Html::tag('CpLink', $chip, ['href' => $editUrl, 'inertia' => false]); + // `:inertia`, bound — a plain `inertia => false` renders nothing at all + // (Html::tag drops false attributes), so the prop falls back to its + // `true` default and the title becomes an Inertia that navigates + // on click. The element edit screen isn't an Inertia page, so that + // visit only ends in a hard redirect anyway. + return Html::tag('CpLink', $chip, ['href' => $editUrl, ':inertia' => 'false']); } /** diff --git a/tests/Feature/Http/Controllers/ContentIndexControllerTest.php b/tests/Feature/Http/Controllers/ContentIndexControllerTest.php index 4b9cd0bf91a..4e4ee4d436a 100644 --- a/tests/Feature/Http/Controllers/ContentIndexControllerTest.php +++ b/tests/Feature/Http/Controllers/ContentIndexControllerTest.php @@ -342,3 +342,24 @@ }) ); }); + +it('renders title cells as element chips carrying the CP element metadata', function () { + // The Vue index identifies an element from the DOM by the `element` class + // and these `data-` attributes — double-click-to-edit reads `data-cp-url` + // and gates on `data-editable`/`data-trashed`. Only `elementChipHtml()` + // emits them; the generic `chipHtml()` does not, and swapping back to it + // silently breaks every element interaction on the index. + EntryModel::factory()->createElement(['title' => 'Hello']); + + get("/{$this->cpTrigger}/content/entries") + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('data', function ($rows) { + $title = (string) (collect($rows)->first()['title'] ?? ''); + + return preg_match('/class="[^"]*\belement\b/', $title) === 1 + && str_contains($title, 'data-cp-url=') + && str_contains($title, 'data-editable'); + }) + ); +}); diff --git a/tests/Feature/Http/Controllers/Elements/EditElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/EditElementControllerTest.php index fa5d70c1a4d..342403f8262 100644 --- a/tests/Feature/Http/Controllers/Elements/EditElementControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/EditElementControllerTest.php @@ -202,6 +202,40 @@ ); }); +/** + * The Vue slideout builds its own `Craft.ElementEditor`, so it needs the same + * settings — but not the same delivery. The injected script looks the + * container up by id, and it runs while Vue still has the panel's subtree + * detached from the document, so the settings travel as a prop instead. + */ +it('sends element editor settings as a prop to an Inertia slideout', function () { + $entry = EntryModel::factory() + ->forSection($this->section) + ->forEntryType($this->entryType) + ->createElement(['title' => 'Prop Delivery', 'slug' => 'prop-delivery']); + + $response = getJson(action(EditElementController::class, [ + 'elementType' => $entry::class, + 'elementId' => $entry->id, + 'siteId' => $entry->siteId, + ]), [ + 'X-Inertia' => 'true', + 'X-Craft-Container-Id' => 'slideout-1', + ])->assertOk(); + + expect($response->json('props.screen.elementEditorSettings')) + ->toMatchArray([ + 'elementId' => $entry->id, + 'canonicalId' => $entry->id, + 'isStatic' => false, + 'isProvisionalDraft' => false, + ]) + // The jQuery hand-off is the other branch's job, and emitting both + // would double-instantiate the editor. + ->and($response->json('props.bodyHtml')) + ->not->toContain('elementEditorSettings'); +}); + it('prevalidates enabled live elements and returns an error summary', function () { $entry = EntryModel::factory() ->forSection($this->section) diff --git a/tests/Feature/Http/Responses/CpScreenSlideoutTest.php b/tests/Feature/Http/Responses/CpScreenSlideoutTest.php new file mode 100644 index 00000000000..8a6116e2785 --- /dev/null +++ b/tests/Feature/Http/Responses/CpScreenSlideoutTest.php @@ -0,0 +1,114 @@ +one()); + + EntryType::factory()->create(); +}); + +function editUrl(): string +{ + return action([EntryTypesController::class, 'edit'], [EntryType::first()->id]); +} + +/** + * A slideout request is any request a CP screen sees as JSON — the convention + * Craft 5 established and Craft 6 inherited. What changes is the wire format: + * the Vue client sends `X-Inertia` and gets an Inertia page, the legacy jQuery + * client doesn't and gets the flat HTML payload. + */ +it('renders a full page when nothing marks the request as a slideout', function () { + get(editUrl()) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('settings/entry-types/Edit') + ->where('screen.mode', 'page')); +}); + +/** + * `assertInertia()` isn't usable here: it reads the `page` view data, which + * only exists on the blade-rendered response. A slideout is always an XHR + * request, so the page object arrives as the JSON body instead. + */ +function slideoutResponse(): TestResponse +{ + return withHeaders([ + 'X-Inertia' => 'true', + 'X-Craft-Container-Id' => 'slideout-1', + ])->getJson(editUrl()); +} + +it('renders an Inertia page for a Vue slideout request', function () { + $response = slideoutResponse()->assertOk()->assertHeader('X-Inertia', 'true'); + + expect($response->json()) + ->toHaveKeys(['component', 'props', 'url', 'version']) + // Same component as the full page — that's the whole point. + ->and($response->json('component'))->toBe('settings/entry-types/Edit') + ->and($response->json('props.screen.mode'))->toBe('slideout') + ->and($response->json('props.screen.containerId'))->toBe('slideout-1') + ->and($response->json('props.screen.namespace'))->toBeString() + // The blade root view never renders for an XHR response, so the + // screen's assets have to travel as props. + ->and($response->json('props'))->toHaveKeys(['headHtml', 'bodyHtml']); +}); + +it('omits full-page chrome from a slideout', function () { + expect(slideoutResponse()->assertOk()->json('props')) + ->not->toHaveKey('crumbs') + ->not->toHaveKey('subnav') + ->not->toHaveKey('sidebar') + ->not->toHaveKey('contextMenu'); +}); + +/** + * The legacy `Craft.CpScreenSlideout` reads these keys directly. Every one of + * them is load-bearing for the jQuery slideout stack, so this pins the payload + * shape rather than just spot-checking it. + */ +it('still returns the legacy flat payload when the client is not Inertia', function () { + $response = withHeaders(['X-Craft-Container-Id' => 'slideout-1']) + ->getJson(editUrl()) + ->assertOk(); + + expect(array_keys($response->json()))->toBe([ + 'editUrl', + 'namespace', + 'title', + 'notice', + 'tabs', + 'bodyClass', + 'formAttributes', + 'action', + 'extraToolbarItems', + 'submitButtonLabel', + 'actionMenu', + 'content', + 'sidebar', + 'errorSummary', + 'headHtml', + 'bodyHtml', + 'deltaNames', + 'initialDeltaValues', + ]); +}); + +it('gives each slideout its own input namespace', function () { + $namespaceFor = fn (string $containerId) => withHeaders([ + 'X-Craft-Container-Id' => $containerId, + ])->getJson(editUrl())->json('namespace'); + + expect($namespaceFor('slideout-1'))->not->toBe($namespaceFor('slideout-2')); +}); diff --git a/vite.config.js b/vite.config.js index 1600ac9f8a1..f04118e1071 100644 --- a/vite.config.js +++ b/vite.config.js @@ -318,24 +318,34 @@ export default defineConfig(({mode}) => { }, }, }), - laravel({ - input: [ - 'resources/js/cp.ts', - 'resources/js/legacy.ts', - 'resources/css/cp.css', - ], - publicDirectory, - hotFile: `${publicDirectory}/hot`, - refresh: [ - // The defaults - 'resources/lang/**', - 'resources/views/**', - 'routes/**', - // Plus ours - 'resources/templates/**', - ], - detectTls: env.VITE_DETECT_TLS ?? undefined, - }), + // Skipped under Vitest, which builds its own Vite server from this same + // config. The Laravel plugin owns the hot file — writing it when a server + // starts and deleting it when one closes — so leaving it in means every + // test run deletes the hot file out from under a running `npm run dev`. + // The CP then silently falls back to stale built assets, which looks like + // "my changes aren't showing up" rather than anything to do with tests. + ...(process.env.VITEST + ? [] + : [ + laravel({ + input: [ + 'resources/js/cp.ts', + 'resources/js/legacy.ts', + 'resources/css/cp.css', + ], + publicDirectory, + hotFile: `${publicDirectory}/hot`, + refresh: [ + // The defaults + 'resources/lang/**', + 'resources/views/**', + 'routes/**', + // Plus ours + 'resources/templates/**', + ], + detectTls: env.VITE_DETECT_TLS ?? undefined, + }), + ]), inertia({ ssr: false, }),