Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion resources/js/components/entries/PublishForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ export default {
},

showLivePreviewButton() {
return !this.isPreviewing && !this.readOnly && !this.isCreating && this.isBase && this.livePreviewUrl;
return !this.isPreviewing && !this.readOnly && this.isBase && this.livePreviewUrl;
},

showVisitUrlButton() {
Expand Down
4 changes: 4 additions & 0 deletions src/Entries/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,10 @@ public function routeData()

public function uri()
{
if (! $this->id() && $uri = $this->getSupplement('live_preview_uri')) {
return $uri;
}

if ($this->id() && Blink::store('entry-uris')->has($this->id())) {
return Blink::store('entry-uris')->get($this->id());
}
Expand Down
40 changes: 4 additions & 36 deletions src/Http/Controllers/CP/Collections/EntriesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
use Statamic\Facades\Blink;
use Statamic\Facades\Entry;
use Statamic\Facades\Site;
use Statamic\Facades\Stache;
use Statamic\Facades\User;
use Statamic\Hooks\CP\EntriesIndexQuery;
use Statamic\Http\Controllers\CP\CpController;
Expand All @@ -31,7 +30,8 @@ class EntriesController extends CpController
{
use ExtractsFromEntryFields,
Hookable,
QueriesFilters;
QueriesFilters,
ResolvesEntryUri;

public function index(FilteredRequest $request, $collection)
{
Expand Down Expand Up @@ -346,15 +346,15 @@ public function create(Request $request, $collection, $site)
'blueprint' => $blueprint->toPublishArray(),
'published' => $collection->defaultPublishState(),
'locale' => $site->handle(),
'localizations' => $this->getAuthorizedSitesForCollection($collection)->map(function ($handle) use ($collection, $site, $blueprint) {
'localizations' => $this->getAuthorizedSitesForCollection($collection)->map(function ($handle) use ($collection, $site, $blueprint, $request) {
return [
'handle' => $handle,
'name' => Site::get($handle)->name(),
'active' => $handle === $site->handle(),
'exists' => false,
'published' => false,
'url' => cp_route('collections.entries.create', [$collection->handle(), $handle, 'blueprint' => $blueprint->handle()]),
'livePreviewUrl' => $collection->route($handle) ? cp_route('collections.entries.preview.create', [$collection->handle(), $handle]) : null,
'livePreviewUrl' => $collection->route($handle) ? cp_route('collections.entries.preview.create', array_filter([$collection->handle(), $handle, 'parent' => $collection->hasStructure() ? $request->parent : null])) : null,
];
})->values()->all(),
'revisionsEnabled' => $collection->revisionsEnabled(),
Expand Down Expand Up @@ -486,38 +486,6 @@ private function validateUniqueUri($entry, $tree, $parent)
throw ValidationException::withMessages(['slug' => __('statamic::validation.unique_uri')]);
}

private function entryUri($entry, $tree, $parent)
{
if (! $entry->route()) {
return null;
}

if (! $tree) {
return app(\Statamic\Contracts\Routing\UrlBuilder::class)
->content($entry)
->merge([
'id' => $entry->id() ?? Stache::generateId(),
])
->build($entry->route());
}

$parent = $parent ? $tree->find($parent) : null;

if ($parent && $parent->isRoot()) {
$parent = null;
}

return app(\Statamic\Contracts\Routing\UrlBuilder::class)
->content($entry)
->merge([
'parent_uri' => $parent ? $parent->uri() : null,
'slug' => $entry->slug(),
'depth' => $parent ? $parent->depth() + 1 : 1,
'is_root' => false,
])
->build($entry->route());
}

protected function getAuthorizedSitesForCollection($collection)
{
return $collection
Expand Down
27 changes: 19 additions & 8 deletions src/Http/Controllers/CP/Collections/EntryPreviewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,40 @@
use Statamic\Contracts\Entries\Entry as EntryContract;
use Statamic\Facades\Entry;
use Statamic\Http\Controllers\CP\PreviewController;
use Statamic\Support\Arr;

class EntryPreviewController extends PreviewController
{
use ResolvesEntryUri;

public function create(Request $request, $collection, $site)
{
$this->authorize('create', [EntryContract::class, $collection]);

$fields = $collection->entryBlueprint($request->blueprint)
$blueprint = $collection->entryBlueprint($request->blueprint);

$values = $blueprint
->fields()
->addValues($preview = $request->preview)
->process();

$values = Arr::except($fields->values()->all(), ['slug']);
->process()
->values()
->except(['slug']);

$entry = Entry::make()
->slug($preview['slug'] ?? 'slug')
->collection($collection)
->locale($site->handle())
->data($values);
->locale($site->handle());

if ($collection->dated()) {
$entry->date($preview['date'] ?? now()->format('Y-m-d-His'));
$date = $values->pull('date');
$entry->date(($date ? $blueprint->field('date')?->fieldtype()->augment($date) : null) ?? now());
}

$entry->data($values->all());

// An entry that hasn't been saved isn't in the tree, so it has no URI
// of its own. Give it the one it will have under its parent page.
if ($structure = $collection->structure()) {
$entry->setSupplement('live_preview_uri', $this->entryUri($entry, $structure->in($site->handle()), $request->parent));
}

return $this->tokenizeAndReturn($request, $entry);
Expand Down
45 changes: 45 additions & 0 deletions src/Http/Controllers/CP/Collections/ResolvesEntryUri.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace Statamic\Http\Controllers\CP\Collections;

use Statamic\Contracts\Routing\UrlBuilder;
use Statamic\Facades\Stache;

trait ResolvesEntryUri
{
/**
* The URI an entry will have once it's saved. A structured entry that
* isn't in the tree yet goes under the given parent page.
*/
protected function entryUri($entry, $tree, $parent)
{
if (! $entry->route()) {
return null;
}

if (! $tree) {
return app(UrlBuilder::class)
->content($entry)
->merge([
'id' => $entry->id() ?? Stache::generateId(),
])
->build($entry->route());
}

$parent = $parent ? $tree->find($parent) : null;

if ($parent && $parent->isRoot()) {
$parent = null;
}

return app(UrlBuilder::class)
->content($entry)
->merge([
'parent_uri' => $parent ? $parent->uri() : null,
'slug' => $entry->slug(),
'depth' => $parent ? $parent->depth() + 1 : 1,
'is_root' => false,
])
->build($entry->route());
}
}
9 changes: 7 additions & 2 deletions src/Stache/Repositories/EntryRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,13 @@ public static function bindings(): array

public function substitute($item)
{
Blink::store('entry-uris')->forget($item->id());
$this->substitutionsById[$item->id()] = $item;
// An entry being previewed before its first save has no id yet,
// so it can only be found by its URI.
if ($id = $item->id()) {
Blink::store('entry-uris')->forget($id);
$this->substitutionsById[$id] = $item;
}

$this->substitutionsByUri[$item->locale().'@'.$item->uri()] = $item;
}

Expand Down
32 changes: 32 additions & 0 deletions tests/Feature/Entries/CreateEntryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,38 @@ public function it_populates_the_form_from_query_string()
);
}

#[Test]
public function the_live_preview_url_keeps_the_parent_of_a_structured_entry()
{
$this->setTestRoles(['test' => ['access cp', 'create test entries']]);
$user = tap(User::make()->assignRole('test'))->save();
Collection::make('test')->routes('{parent_uri}/{slug}')->structureContents(['max_depth' => 10])->save();

$this
->actingAs($user)
->get(cp_route('collections.entries.create', ['test', 'en']).'?parent=the-parent')
->assertOk()
->assertInertia(fn ($page) => $page
->where('localizations.0.livePreviewUrl', cp_route('collections.entries.preview.create', ['test', 'en', 'parent' => 'the-parent']))
);
}

#[Test]
public function the_live_preview_url_has_no_parent_for_a_collection_without_a_structure()
{
$this->setTestRoles(['test' => ['access cp', 'create test entries']]);
$user = tap(User::make()->assignRole('test'))->save();
Collection::make('test')->routes('/test/{slug}')->save();

$this
->actingAs($user)
->get(cp_route('collections.entries.create', ['test', 'en']).'?parent=the-parent')
->assertOk()
->assertInertia(fn ($page) => $page
->where('localizations.0.livePreviewUrl', cp_route('collections.entries.preview.create', ['test', 'en']))
);
}

#[Test]
public function the_publish_state_cannot_be_managed_without_permission()
{
Expand Down
114 changes: 114 additions & 0 deletions tests/Feature/Entries/LivePreviewNewEntryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

namespace Tests\Feature\Entries;

use Facades\Tests\Factories\EntryFactory;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Collection;
use Statamic\Facades\User;
use Tests\FakesRoles;
use Tests\FakesViews;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class LivePreviewNewEntryTest extends TestCase
{
use FakesRoles;
use FakesViews;
use PreventSavingStacheItemsToDisk;

public function setUp(): void
{
parent::setUp();

$this->withFakeViews();
$this->viewShouldReturnRaw('layout', '{{ template_content }}');
$this->viewShouldReturnRaw('entry', '<h1>{{ title }}</h1> {{ url }}');
}

#[Test]
public function it_previews_a_new_entry_in_a_dated_collection()
{
Collection::make('blog')->routes('/blog/{year}/{slug}')->dated(true)->template('entry')->save();

$url = $this->previewUrl('blog', [
'title' => 'Dated entry',
'slug' => 'dated-entry',
'date' => '2024-03-05T14:30:00.000Z',
]);

$this->assertStringStartsWith('http://localhost/blog/2024/dated-entry?', $url);
$this->get($url)->assertOk()->assertSee('<h1>Dated entry</h1>', false);
}

#[Test]
public function it_previews_a_new_entry_in_a_dated_collection_without_a_date()
{
Collection::make('blog')->routes('/blog/{year}/{slug}')->dated(true)->template('entry')->save();

$url = $this->previewUrl('blog', ['title' => 'Undated entry', 'slug' => 'undated-entry']);

$this->assertStringStartsWith('http://localhost/blog/'.now()->year.'/undated-entry?', $url);
$this->get($url)->assertOk()->assertSee('<h1>Undated entry</h1>', false);
}

#[Test]
public function it_previews_a_new_top_level_entry_in_a_structured_collection()
{
$this->structuredCollection();

$url = $this->previewUrl('pages', ['title' => 'New page', 'slug' => 'new-page']);

$this->assertStringStartsWith('http://localhost/new-page?', $url);
$this->get($url)->assertOk()->assertSee('<h1>New page</h1>', false)->assertDontSee('Home');
}

#[Test]
public function it_previews_a_new_entry_under_its_parent_in_a_structured_collection()
{
$this->structuredCollection();

$url = $this->previewUrl('pages', ['title' => 'Child page', 'slug' => 'child-page'], parent: 'about');

$this->assertStringStartsWith('http://localhost/about/child-page?', $url);
$this->get($url)->assertOk()->assertSee('<h1>Child page</h1>', false);
}

#[Test]
public function it_treats_the_root_page_as_no_parent()
{
$this->structuredCollection();

$url = $this->previewUrl('pages', ['title' => 'Sibling page', 'slug' => 'sibling-page'], parent: 'home');

$this->assertStringStartsWith('http://localhost/sibling-page?', $url);
$this->get($url)->assertOk()->assertSee('<h1>Sibling page</h1>', false);
}

private function structuredCollection(): void
{
$collection = tap(Collection::make('pages')->routes('{parent_uri}/{slug}')->template('entry')->structureContents(['root' => true]))->save();

EntryFactory::id('home')->slug('home')->collection('pages')->data(['title' => 'Home'])->create();
EntryFactory::id('about')->slug('about')->collection('pages')->data(['title' => 'About'])->create();

$collection->structure()->in('en')->tree([
['entry' => 'home'],
['entry' => 'about'],
])->save();
}

private function previewUrl(string $collection, array $values, ?string $parent = null): string
{
$uri = "/cp/collections/{$collection}/entries/create/en/preview".($parent ? "?parent={$parent}" : '');

return $this->actingAs($this->user())->postJson($uri, ['preview' => $values])->assertOk()->json('url');
}

private function user()
{
$this->setTestRoles(['test' => ['access cp', 'create blog entries', 'create pages entries']]);

return User::make()->assignRole('test')->save();
}
}
20 changes: 20 additions & 0 deletions tests/Feature/Entries/SubstitutesEntryForLivePreviewTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Facades\Tests\Factories\EntryFactory;
use Illuminate\Support\Facades\Route;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Collection;
use Statamic\Facades\Entry;
use Statamic\Facades\Token;
use Statamic\View\View;
Expand Down Expand Up @@ -70,6 +71,25 @@ public function it_substitutes()
->assertHeader('X-Statamic-Live-Preview', true);
}

#[Test]
public function it_substitutes_an_entry_that_has_not_been_saved_yet()
{
Collection::make('test')->routes('/test/{slug}')->template('entry')->save();
$this->viewShouldReturnRaw('layout', '{{ template_content }}');
$this->viewShouldReturnRaw('entry', '{{ title }} {{ foo }}');

$substitute = EntryFactory::collection('test')->slug('delta')->data(['title' => 'New title', 'foo' => 'New foo'])->make();
LivePreview::tokenize('test-token', $substitute);

$this->assertNull($substitute->id());

$this->get('/test/delta?token=test-token')
->assertOk()
->assertSee('New title')
->assertSee('New foo')
->assertHeader('X-Statamic-Live-Preview', true);
}

#[Test]
public function it_doesnt_substitute()
{
Expand Down
Loading