From 894f375b157b03dfefa483529bc2c4f18ab6a9eb Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 12 Aug 2026 12:28:11 +0100 Subject: [PATCH 1/4] add an endpoint for generating an entry's title from the collection's `title_format` `EntryTitleFormatController` builds an entry from the submitted publish values and returns `autoGeneratedTitle()`, so the control panel can show the title before the entry has been saved. Co-Authored-By: Claude Opus 5 --- routes/cp.php | 3 + .../CP/Collections/EntriesController.php | 6 + .../EntryTitleFormatController.php | 64 +++++++ .../Feature/Entries/EntryTitleFormatTest.php | 171 ++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 src/Http/Controllers/CP/Collections/EntryTitleFormatController.php create mode 100644 tests/Feature/Entries/EntryTitleFormatTest.php diff --git a/routes/cp.php b/routes/cp.php index 2626fc0f7f2..c0e022faaba 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -40,6 +40,7 @@ use Statamic\Http\Controllers\CP\Collections\EntryActionController; use Statamic\Http\Controllers\CP\Collections\EntryPreviewController; use Statamic\Http\Controllers\CP\Collections\EntryRevisionsController; +use Statamic\Http\Controllers\CP\Collections\EntryTitleFormatController; use Statamic\Http\Controllers\CP\Collections\LocalizeEntryController; use Statamic\Http\Controllers\CP\Collections\PublishedEntriesController; use Statamic\Http\Controllers\CP\Collections\ReorderCollectionBlueprintsController; @@ -188,6 +189,7 @@ Route::post('actions/list', [EntryActionController::class, 'bulkActions'])->name('collections.entries.actions.bulk'); Route::get('create/{site}', [EntriesController::class, 'create'])->name('collections.entries.create'); Route::post('create/{site}/preview', [EntryPreviewController::class, 'create'])->name('collections.entries.preview.create'); + Route::post('create/{site}/title-format', [EntryTitleFormatController::class, 'create'])->name('collections.entries.title-format.create'); Route::post('reorder', ReorderEntriesController::class)->name('collections.entries.reorder'); Route::post('{site}', [EntriesController::class, 'store'])->name('collections.entries.store'); @@ -209,6 +211,7 @@ Route::post('restore-revision', RestoreEntryRevisionController::class)->name('collections.entries.restore-revision'); Route::post('preview', [EntryPreviewController::class, 'edit'])->name('collections.entries.preview.edit'); Route::get('preview', [EntryPreviewController::class, 'show'])->name('collections.entries.preview.popout'); + Route::post('title-format', [EntryTitleFormatController::class, 'edit'])->name('collections.entries.title-format.edit'); Route::patch('/', [EntriesController::class, 'update'])->name('collections.entries.update'); Route::get('{slug}', fn ($collection, $entry, $slug) => redirect($entry->editUrl())); }); diff --git a/src/Http/Controllers/CP/Collections/EntriesController.php b/src/Http/Controllers/CP/Collections/EntriesController.php index acd8b573d60..a614ecea50e 100644 --- a/src/Http/Controllers/CP/Collections/EntriesController.php +++ b/src/Http/Controllers/CP/Collections/EntriesController.php @@ -130,6 +130,9 @@ public function edit(Request $request, $collection, $entry) 'restore' => $entry->restoreRevisionUrl(), 'createRevision' => $entry->createRevisionUrl(), 'editBlueprint' => cp_route('blueprints.collections.edit', [$collection, $blueprint]), + 'titleFormat' => $collection->autoGeneratesTitles() + ? cp_route('collections.entries.title-format.edit', [$collection->handle(), $entry->id()]) + : null, ], 'values' => array_merge($values, ['id' => $entry->id()]), 'extraValues' => $extraValues, @@ -324,6 +327,9 @@ public function create(Request $request, $collection, $site) 'actions' => [ 'save' => cp_route('collections.entries.store', [$collection->handle(), $site->handle()]), 'editBlueprint' => cp_route('blueprints.collections.edit', [$collection, $blueprint]), + 'titleFormat' => $collection->autoGeneratesTitles() + ? cp_route('collections.entries.title-format.create', [$collection->handle(), $site->handle()]) + : null, ], 'values' => $values->all(), 'extraValues' => [ diff --git a/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php b/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php new file mode 100644 index 00000000000..00feeca3fe4 --- /dev/null +++ b/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php @@ -0,0 +1,64 @@ +authorize('create', [EntryContract::class, $collection, $site]); + + $this->ensureCollectionAutoGeneratesTitles($collection); + + $blueprint = $collection->entryBlueprint($request->blueprint); + $values = $this->processedValues($blueprint, $request); + + $entry = Entry::make() + ->collection($collection) + ->locale($site->handle()); + + if ($collection->dated()) { + $entry->date($blueprint->field('date')->fieldtype()->augment($values->pull('date'))); + } + + return ['title' => $entry->data($values->except(['title', 'slug']))->autoGeneratedTitle()]; + } + + public function edit(Request $request, $collection, $entry) + { + $this->authorize('update', $entry); + + $this->ensureCollectionAutoGeneratesTitles($collection); + + $entry = $entry->fromWorkingCopy(); + $values = $this->processedValues($blueprint = $entry->blueprint(), $request); + + if ($collection->dated() && $values->has('date')) { + $entry->date($blueprint->field('date')->fieldtype()->augment($values->pull('date'))); + } + + return ['title' => $entry->merge($values->except(['title', 'slug']))->autoGeneratedTitle()]; + } + + private function ensureCollectionAutoGeneratesTitles($collection) + { + if (! $collection->autoGeneratesTitles()) { + throw new NotFoundHttpException; + } + } + + private function processedValues($blueprint, $request) + { + return $blueprint + ->fields() + ->addValues($request->input('values', [])) + ->process() + ->values(); + } +} diff --git a/tests/Feature/Entries/EntryTitleFormatTest.php b/tests/Feature/Entries/EntryTitleFormatTest.php new file mode 100644 index 00000000000..dd4adf29e99 --- /dev/null +++ b/tests/Feature/Entries/EntryTitleFormatTest.php @@ -0,0 +1,171 @@ +setTestRoles(['test' => ['access cp']]); + $user = tap(User::make()->assignRole('test'))->save(); + $collection = tap(Collection::make('test')->titleFormats('{first_name} {last_name}'))->save(); + + $this + ->actingAs($user) + ->generateForCreate($collection, ['first_name' => 'Michael']) + ->assertForbidden(); + } + + #[Test] + public function it_generates_the_title_when_creating_an_entry() + { + [$user, $collection] = $this->seedUserAndCollection(); + $collection->titleFormats('{first_name} {last_name}')->save(); + $this->seedBlueprintFields($collection, [ + 'first_name' => ['type' => 'text'], + 'last_name' => ['type' => 'text'], + ]); + + $this + ->actingAs($user) + ->generateForCreate($collection, ['first_name' => 'Michael', 'last_name' => 'Aerni']) + ->assertOk() + ->assertExactJson(['title' => 'Michael Aerni']); + } + + #[Test] + public function it_generates_the_title_when_editing_an_entry() + { + [$user, $collection] = $this->seedUserAndCollection(); + $collection->titleFormats('{first_name} {last_name}')->save(); + $this->seedBlueprintFields($collection, [ + 'first_name' => ['type' => 'text'], + 'last_name' => ['type' => 'text'], + ]); + + $entry = EntryFactory::collection($collection) + ->slug('michael-aerni') + ->data(['title' => 'Michael Aerni', 'first_name' => 'Michael', 'last_name' => 'Aerni']) + ->create(); + + $this + ->actingAs($user) + ->generateForEdit($entry, ['first_name' => 'Ruth', 'last_name' => 'Aerni']) + ->assertOk() + ->assertExactJson(['title' => 'Ruth Aerni']); + } + + #[Test] + public function it_generates_the_title_using_the_title_format_of_the_entrys_site() + { + $this->setSites([ + 'en' => ['locale' => 'en', 'url' => '/'], + 'fr' => ['locale' => 'fr', 'url' => '/fr/'], + ]); + + [$user, $collection] = $this->seedUserAndCollection(); + $collection->sites(['en', 'fr'])->titleFormats([ + 'en' => '{first_name} {last_name}', + 'fr' => '{last_name}, {first_name}', + ])->save(); + $this->seedBlueprintFields($collection, [ + 'first_name' => ['type' => 'text'], + 'last_name' => ['type' => 'text'], + ]); + + $entry = EntryFactory::collection($collection) + ->locale('fr') + ->slug('michael-aerni') + ->data(['title' => 'Michael Aerni', 'first_name' => 'Michael', 'last_name' => 'Aerni']) + ->create(); + + $this + ->actingAs($user) + ->generateForEdit($entry, ['first_name' => 'Ruth', 'last_name' => 'Aerni']) + ->assertOk() + ->assertExactJson(['title' => 'Aerni, Ruth']); + } + + #[Test] + public function it_generates_the_title_using_the_submitted_date() + { + [$user, $collection] = $this->seedUserAndCollection(); + $collection->dated(true)->titleFormats('{{ first_name }} {{ last_name }} ({{ date format="Y" }})')->save(); + $this->seedBlueprintFields($collection, [ + 'first_name' => ['type' => 'text'], + 'last_name' => ['type' => 'text'], + ]); + + $this + ->actingAs($user) + ->generateForCreate($collection, ['first_name' => 'Michael', 'last_name' => 'Aerni', 'date' => '2023-01-18']) + ->assertOk() + ->assertExactJson(['title' => 'Michael Aerni (2023)']); + } + + #[Test] + public function it_404s_when_the_collection_doesnt_generate_titles() + { + [$user, $collection] = $this->seedUserAndCollection(); + + $this + ->actingAs($user) + ->generateForCreate($collection, ['first_name' => 'Michael']) + ->assertNotFound(); + } + + private function seedUserAndCollection() + { + $this->setTestRoles(['test' => [ + 'access cp', + 'create test entries', + 'edit test entries', + 'access en site', + 'access fr site', + ]]); + $user = tap(User::make()->assignRole('test'))->save(); + $collection = tap(Collection::make('test'))->save(); + + return [$user, $collection]; + } + + private function seedBlueprintFields($collection, $fields) + { + $blueprint = Blueprint::makeFromFields($fields); + + BlueprintRepository::partialMock(); + BlueprintRepository::shouldReceive('in') + ->with('collections/'.$collection->handle()) + ->andReturn(collect([$blueprint])); + } + + private function generateForCreate($collection, $values) + { + return $this->postJson( + cp_route('collections.entries.title-format.create', [$collection->handle(), 'en']), + ['values' => $values] + ); + } + + private function generateForEdit($entry, $values) + { + return $this->postJson( + cp_route('collections.entries.title-format.edit', [$entry->collectionHandle(), $entry->id()]), + ['values' => $values] + ); + } +} From a8dbfec0570d0fd3a1196fb37249cf9f45e94b92 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 12 Aug 2026 12:28:15 +0100 Subject: [PATCH 2/4] generate the title as values change so slugs generate in real time The `slug` fieldtype already generates from `title`, but with a `title_format` the title stayed empty until the entry was saved, leaving nothing to slugify. Co-Authored-By: Claude Opus 5 --- .../js/components/entries/PublishForm.vue | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/resources/js/components/entries/PublishForm.vue b/resources/js/components/entries/PublishForm.vue index 2c888064b35..2bd575ce1f7 100644 --- a/resources/js/components/entries/PublishForm.vue +++ b/resources/js/components/entries/PublishForm.vue @@ -285,6 +285,7 @@ import { Stack, } from '@ui'; import resetValuesFromResponse from '@/util/resetValuesFromResponse.js'; +import debounce from '@/util/debounce.js'; import { computed, ref } from 'vue'; import { Pipeline, Request, BeforeSaveHooks, AfterSaveHooks, PipelineStopped } from '@ui/Publish/SavePipeline.js'; import { router } from '@inertiajs/vue3'; @@ -629,6 +630,36 @@ export default { } }, + generateTitle() { + const values = this.titleFormatValues(); + const serialized = JSON.stringify(values); + + if (serialized === this.lastTitleFormatValues) return; + this.lastTitleFormatValues = serialized; + + this.titleRequest?.abort(); + this.titleRequest = new AbortController(); + + this.$axios + .post( + this.actions.titleFormat, + { blueprint: this.fieldset.handle, values }, + { signal: this.titleRequest.signal }, + ) + .then(({ data }) => { + if (data.title !== this.values.title) this.$refs.container.setFieldValue('title', data.title); + }) + .catch((e) => { + if (e.code !== 'ERR_CANCELED') throw e; + }); + }, + + titleFormatValues() { + const { title, slug, ...values } = this.values; + + return values; + }, + localizationSelected(localization) { if (!this.canSave) { if (localization.exists) this.editLocalization(localization); @@ -877,6 +908,11 @@ export default { created() { window.history.replaceState({}, document.title, document.location.href.replace('created=true', '')); + if (this.actions.titleFormat) { + this.lastTitleFormatValues = JSON.stringify(this.titleFormatValues()); + this.$watch('values', debounce(() => this.generateTitle(), 300), { deep: true }); + } + this.selectedOrigin = this.originBehavior === 'active' ? this.localizations.find((l) => l.active)?.handle From fa50032b9e886c0915d0aa27962b70d917ff52be Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 13 Aug 2026 09:43:23 +0100 Subject: [PATCH 3/4] don't let a stale slug win when it's still being auto generated the browser generates the slug asynchronously, so what it submits can lag behind the values it came from. the slug fieldtype now surfaces slugify's `shouldSlugify` flag through the field's meta, the publish form sends it as `_auto_slug`, and `resolveSlug()` derives the slug itself while it's set. `$request->title` is also ignored on collections that auto generate titles, since `Entry::save()` overwrites it anyway. also from review: - gate the title format endpoint on `can('update')` - null check the blueprint in `EntryTitleFormatController::create()` - honour the blueprint the form sends when editing - only pull the date when it was submitted - ship the fields the format references so the form can skip requests for everything else --- .../js/components/entries/PublishForm.vue | 16 +- .../components/fieldtypes/SlugFieldtype.vue | 7 + resources/js/pages/entries/Create.vue | 2 + resources/js/pages/entries/Edit.vue | 2 + .../components/entries/TitleFormat.test.js | 111 +++++++++++ .../fieldtypes/SlugFieldtype.test.js | 51 +++++ src/Entries/Entry.php | 26 ++- .../CP/Collections/EntriesController.php | 27 +-- .../EntryTitleFormatController.php | 29 ++- tests/Data/Entries/EntryTest.php | 23 +++ .../Feature/Entries/EntryTitleFormatTest.php | 182 +++++++++++++++++- tests/Feature/Entries/StoreEntryTest.php | 64 ++++++ tests/Feature/Entries/UpdateEntryTest.php | 47 +++++ 13 files changed, 557 insertions(+), 30 deletions(-) create mode 100644 resources/js/tests/components/entries/TitleFormat.test.js create mode 100644 resources/js/tests/components/fieldtypes/SlugFieldtype.test.js diff --git a/resources/js/components/entries/PublishForm.vue b/resources/js/components/entries/PublishForm.vue index 2bd575ce1f7..3c2a993877b 100644 --- a/resources/js/components/entries/PublishForm.vue +++ b/resources/js/components/entries/PublishForm.vue @@ -77,7 +77,7 @@ :blueprint="fieldset" v-model="values" :extra-values="extraValues" - :meta="meta" + v-model:meta="meta" :origin-values="originValues" :origin-meta="originMeta" :errors="errors" @@ -353,11 +353,13 @@ export default { previewTargets: Array, autosaveInterval: Number, parent: String, + initialTitleFormat: Object, }, data() { return { actions: this.initialActions, + titleFormat: this.initialTitleFormat, localizing: false, trackDirtyState: true, fieldset: this.initialFieldset, @@ -565,6 +567,7 @@ export default { _blueprint: this.fieldset.handle, _localized: this.localizedFields, _parent: this.parent, + _auto_slug: this.meta.slug?.auto ?? false, }), new AfterSaveHooks('entry', { collection: this.collectionHandle, @@ -631,6 +634,8 @@ export default { }, generateTitle() { + if (!this.titleFormat) return; + const values = this.titleFormatValues(); const serialized = JSON.stringify(values); @@ -642,7 +647,7 @@ export default { this.$axios .post( - this.actions.titleFormat, + this.titleFormat.url, { blueprint: this.fieldset.handle, values }, { signal: this.titleRequest.signal }, ) @@ -655,9 +660,9 @@ export default { }, titleFormatValues() { - const { title, slug, ...values } = this.values; + const fields = this.titleFormat.fields.filter((field) => field in this.values); - return values; + return Object.fromEntries(fields.map((field) => [field, this.values[field]])); }, localizationSelected(localization) { @@ -716,6 +721,7 @@ export default { this.collection = data.collection; this.title = data.editing ? data.values.title : this.title; this.actions = data.actions; + this.titleFormat = data.titleFormat; this.itemActions = data.itemActions; this.fieldset = data.blueprint; this.permalink = data.permalink; @@ -908,7 +914,7 @@ export default { created() { window.history.replaceState({}, document.title, document.location.href.replace('created=true', '')); - if (this.actions.titleFormat) { + if (this.titleFormat) { this.lastTitleFormatValues = JSON.stringify(this.titleFormatValues()); this.$watch('values', debounce(() => this.generateTitle(), 300), { deep: true }); } diff --git a/resources/js/components/fieldtypes/SlugFieldtype.vue b/resources/js/components/fieldtypes/SlugFieldtype.vue index d9dda4158e7..f52d9d93201 100644 --- a/resources/js/components/fieldtypes/SlugFieldtype.vue +++ b/resources/js/components/fieldtypes/SlugFieldtype.vue @@ -112,6 +112,13 @@ export default { mounted() { if (this.config.required && !this.value) this.update(this.$refs.slugify.slug); + + // Lets the publish form know whether the user has taken ownership of the slug. + this.$watch( + () => this.$refs.slugify.shouldSlugify, + (auto) => this.updateMeta({ ...this.meta, auto }), + { immediate: true }, + ); }, methods: { diff --git a/resources/js/pages/entries/Create.vue b/resources/js/pages/entries/Create.vue index 83eca995428..f7c852de152 100644 --- a/resources/js/pages/entries/Create.vue +++ b/resources/js/pages/entries/Create.vue @@ -5,6 +5,7 @@ import Head from '@/pages/layout/Head.vue'; defineProps([ 'actions', + 'titleFormat', 'collection', 'collectionCreateLabel', 'blueprint', @@ -34,6 +35,7 @@ function saved(response) { :is-creating="true" publish-container="base" :initial-actions="actions" + :initial-title-format="titleFormat" method="post" :initial-title="collectionCreateLabel" :collection-handle="collection" diff --git a/resources/js/pages/entries/Edit.vue b/resources/js/pages/entries/Edit.vue index 40962934ac9..6ec87ef4219 100644 --- a/resources/js/pages/entries/Edit.vue +++ b/resources/js/pages/entries/Edit.vue @@ -4,6 +4,7 @@ import Head from '@/pages/layout/Head.vue'; defineProps([ 'actions', + 'titleFormat', 'collection', 'title', 'reference', @@ -39,6 +40,7 @@ defineProps([ key; +window.Statamic = { $commandPalette: { add: () => {}, category: {}, remove: () => {} } }; + +const ContainerStub = defineComponent({ + methods: { + setFieldValue(handle, value) { + this.$attrs.modelValue[handle] = value; + }, + }, + render: () => h('div'), +}); + +let post; + +function mountForm(values, titleFormat) { + return shallowMount(PublishForm, { + props: { + publishContainer: 'base', + initialFieldset: { handle: 'article', tabs: [] }, + initialValues: values, + initialMeta: {}, + initialLocalizations: [], + initialTitleFormat: titleFormat, + collectionHandle: 'articles', + initialActions: {}, + method: 'post', + }, + global: { + stubs: { PublishContainer: ContainerStub }, + mocks: { + $axios: { post }, + $progress: { isComplete: () => true, loading: () => {} }, + $config: { get: () => 'ltr' }, + $preferences: { get: () => null }, + $keys: { bindGlobal: () => {}, unbind: () => {} }, + $events: { $on: () => {}, $off: () => {}, $emit: () => {} }, + }, + }, + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + post = vi.fn(() => Promise.resolve({ data: { title: 'Michael Aerni' } })); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('the title is generated from the fields the format references', async () => { + const wrapper = mountForm( + { title: null, slug: null, first_name: 'Michael', last_name: null, body: 'Lorem ipsum' }, + { url: '/title-format', fields: ['first_name', 'last_name'] }, + ); + + await wrapper.setData({ values: { last_name: 'Aerni' } }); + + await vi.advanceTimersByTimeAsync(299); + expect(post).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(post).toHaveBeenCalledOnce(); + expect(post.mock.calls[0][0]).toBe('/title-format'); + expect(post.mock.calls[0][1]).toEqual({ + blueprint: 'article', + values: { first_name: 'Michael', last_name: 'Aerni' }, + }); + + expect(wrapper.vm.values.title).toBe('Michael Aerni'); +}); + +test('the title is not generated when a field the format ignores changes', async () => { + const wrapper = mountForm( + { title: null, slug: null, first_name: 'Michael', body: 'Lorem ipsum' }, + { url: '/title-format', fields: ['first_name'] }, + ); + + await wrapper.setData({ values: { body: 'Dolor sit amet' } }); + await vi.advanceTimersByTimeAsync(300); + + expect(post).not.toHaveBeenCalled(); +}); + +test('the generated title does not trigger another request', async () => { + const wrapper = mountForm( + { title: null, slug: null, first_name: 'Michael', last_name: 'Aerni' }, + { url: '/title-format', fields: ['first_name', 'last_name'] }, + ); + + await wrapper.setData({ values: { first_name: 'Ruth' } }); + await vi.advanceTimersByTimeAsync(300); + expect(post).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(300); + expect(post).toHaveBeenCalledOnce(); +}); + +test('the title is never generated without a title format', async () => { + const wrapper = mountForm({ title: 'Michael Aerni', slug: null, first_name: 'Michael' }, null); + + await wrapper.setData({ values: { first_name: 'Ruth' } }); + await vi.advanceTimersByTimeAsync(300); + + expect(post).not.toHaveBeenCalled(); +}); diff --git a/resources/js/tests/components/fieldtypes/SlugFieldtype.test.js b/resources/js/tests/components/fieldtypes/SlugFieldtype.test.js new file mode 100644 index 00000000000..14dbbeb8900 --- /dev/null +++ b/resources/js/tests/components/fieldtypes/SlugFieldtype.test.js @@ -0,0 +1,51 @@ +import { mount } from '@vue/test-utils'; +import { expect, test } from 'vitest'; +import { ref } from 'vue'; +import SlugFieldtype from '@/components/fieldtypes/SlugFieldtype.vue'; +import Slugify from '@/components/slugs/Slugify.vue'; +import { publishContextKey } from '@/components/ui'; + +window.__ = (key) => key; +window.Statamic = { $config: { get: () => [{ handle: 'en', lang: 'en', direction: 'ltr' }] } }; + +function mountFieldtype(value) { + return mount(SlugFieldtype, { + props: { + handle: 'slug', + value, + meta: null, + config: { generate: true, from: 'title', async: false }, + }, + global: { + components: { Slugify }, + provide: { + [publishContextKey]: { + values: ref({ title: 'Michael Aerni', slug: value }), + site: ref('en'), + }, + }, + mocks: { + $slug: { + in: () => ({ separatedBy: () => ({ create: (str) => str.toLowerCase().replace(/\s/g, '-') }) }), + }, + $events: { $on: () => {}, $off: () => {} }, + }, + }, + }); +} + +test('the slug is flagged as auto generated until the user edits it', async () => { + const wrapper = mountFieldtype(null); + + expect(wrapper.emitted('update:meta').at(-1)).toEqual([{ auto: true }]); + + await wrapper.find('input').setValue('something-else'); + + expect(wrapper.emitted('update:meta').at(-1)).toEqual([{ auto: false }]); +}); + +test('the slug is not flagged as auto generated when the entry already has one', () => { + const wrapper = mountFieldtype('michael-aerni'); + + expect(wrapper.emitted('update:meta').at(-1)).toEqual([{ auto: false }]); +}); diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 1bf3e4c0f84..fac487166d7 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -1099,13 +1099,7 @@ public function resolveGqlValue($field) public function autoGeneratedTitle() { - $format = $this->collection()->titleFormat($this->locale()); - - if (! Str::contains($format, '{{')) { - $format = preg_replace_callback('/{\s*([a-zA-Z0-9_\-\:\.]+)\s*}/', function ($match) { - return "{{ {$match[1]} }}"; - }, $format); - } + $format = $this->antlersTitleFormat(); // Since the slug is generated from the title, we'll avoid augmenting // the slug which could result in an infinite loop in some cases. @@ -1118,6 +1112,24 @@ public function autoGeneratedTitle() return trim($title); } + public function autoGeneratedTitleFields() + { + return Antlers::identifiers($this->antlersTitleFormat()); + } + + private function antlersTitleFormat() + { + $format = $this->collection()->titleFormat($this->locale()); + + if (Str::contains($format, '{{')) { + return $format; + } + + return preg_replace_callback('/{\s*([a-zA-Z0-9_\-\:\.]+)\s*}/', function ($match) { + return "{{ {$match[1]} }}"; + }, $format); + } + public function previewTargets() { return $this->collection()->previewTargets()->map(function ($target) { diff --git a/src/Http/Controllers/CP/Collections/EntriesController.php b/src/Http/Controllers/CP/Collections/EntriesController.php index a614ecea50e..32168c80e3a 100644 --- a/src/Http/Controllers/CP/Collections/EntriesController.php +++ b/src/Http/Controllers/CP/Collections/EntriesController.php @@ -130,10 +130,11 @@ public function edit(Request $request, $collection, $entry) 'restore' => $entry->restoreRevisionUrl(), 'createRevision' => $entry->createRevisionUrl(), 'editBlueprint' => cp_route('blueprints.collections.edit', [$collection, $blueprint]), - 'titleFormat' => $collection->autoGeneratesTitles() - ? cp_route('collections.entries.title-format.edit', [$collection->handle(), $entry->id()]) - : null, ], + 'titleFormat' => $collection->autoGeneratesTitles() && User::current()->can('update', $entry) ? [ + 'url' => cp_route('collections.entries.title-format.edit', [$collection->handle(), $entry->id()]), + 'fields' => $entry->autoGeneratedTitleFields(), + ] : null, 'values' => array_merge($values, ['id' => $entry->id()]), 'extraValues' => $extraValues, 'meta' => $meta, @@ -294,7 +295,7 @@ public function create(Request $request, $collection, $site) $blueprint->ensureFieldHasConfig('author', ['visibility' => 'read_only']); } - $entry = Entry::make()->collection($collection); + $entry = Entry::make()->collection($collection)->locale($site->handle()); $values = $entry->values()->all(); @@ -327,10 +328,11 @@ public function create(Request $request, $collection, $site) 'actions' => [ 'save' => cp_route('collections.entries.store', [$collection->handle(), $site->handle()]), 'editBlueprint' => cp_route('blueprints.collections.edit', [$collection, $blueprint]), - 'titleFormat' => $collection->autoGeneratesTitles() - ? cp_route('collections.entries.title-format.create', [$collection->handle(), $site->handle()]) - : null, ], + 'titleFormat' => $collection->autoGeneratesTitles() ? [ + 'url' => cp_route('collections.entries.title-format.create', [$collection->handle(), $site->handle()]), + 'fields' => $entry->autoGeneratedTitleFields(), + ] : null, 'values' => $values->all(), 'extraValues' => [ 'depth' => 1, @@ -451,15 +453,18 @@ public function store(Request $request, $collection, $site) private function resolveSlug($request) { return function ($entry) use ($request) { - if ($request->slug) { + // An auto generated slug lags behind the values it came from, so we derive it here instead. + if ($request->slug && ! $request->boolean('_auto_slug')) { return $request->slug; } - if ($entry->blueprint()->hasField('slug')) { - return Str::slug($request->title ?? $entry->autoGeneratedTitle(), '-', $entry->site()->lang()); + if (! $entry->blueprint()->hasField('slug')) { + return null; } - return null; + $title = $entry->collection()->autoGeneratesTitles() ? null : $request->title; + + return Str::slug($title ?? $entry->autoGeneratedTitle(), '-', $entry->site()->lang()); }; } diff --git a/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php b/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php index 00feeca3fe4..45f134706d8 100644 --- a/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php +++ b/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Statamic\Contracts\Entries\Entry as EntryContract; +use Statamic\Facades\Blink; use Statamic\Facades\Entry; use Statamic\Http\Controllers\CP\CpController; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -17,13 +18,19 @@ public function create(Request $request, $collection, $site) $this->ensureCollectionAutoGeneratesTitles($collection); $blueprint = $collection->entryBlueprint($request->blueprint); - $values = $this->processedValues($blueprint, $request); + + if (! $blueprint) { + throw new \Exception(__('A valid blueprint is required.')); + } $entry = Entry::make() ->collection($collection) + ->blueprint($blueprint->handle()) ->locale($site->handle()); - if ($collection->dated()) { + $values = $this->processedValues($blueprint, $request); + + if ($collection->dated() && $values->has('date')) { $entry->date($blueprint->field('date')->fieldtype()->augment($values->pull('date'))); } @@ -37,7 +44,14 @@ public function edit(Request $request, $collection, $entry) $this->ensureCollectionAutoGeneratesTitles($collection); $entry = $entry->fromWorkingCopy(); - $values = $this->processedValues($blueprint = $entry->blueprint(), $request); + + if ($handle = $request->blueprint) { + Blink::forget("entry-{$entry->id()}-blueprint"); + $entry->blueprint($handle); + } + + $blueprint = $entry->blueprint(); + $values = $this->processedValues($blueprint, $request); if ($collection->dated() && $values->has('date')) { $entry->date($blueprint->field('date')->fieldtype()->augment($values->pull('date'))); @@ -55,10 +69,15 @@ private function ensureCollectionAutoGeneratesTitles($collection) private function processedValues($blueprint, $request) { + $values = $request->input('values', []); + + // Only the fields the title format references get submitted, and processing + // them fills in the rest, which would wipe out what the entry already has. return $blueprint ->fields() - ->addValues($request->input('values', [])) + ->addValues($values) ->process() - ->values(); + ->values() + ->only(array_keys($values)); } } diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index 2f800df2f60..a87b106f23e 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -2530,6 +2530,29 @@ public static function autoGeneratedTitleProvider() ]; } + #[Test] + #[DataProvider('autoGeneratedTitleFieldsProvider')] + public function it_gets_the_fields_referenced_by_the_auto_generated_title($format, $expected) + { + $collection = tap(Collection::make('test')->titleFormats($format))->save(); + + $entry = (new Entry)->id('entry-id')->locale('en')->collection($collection); + + $this->assertEquals($expected, $entry->autoGeneratedTitleFields()); + } + + public static function autoGeneratedTitleFieldsProvider() + { + return [ + 'antlers' => ['{{ product }} by {{ company }}', ['product', 'company']], + 'mustache' => ['{product} by {company}', ['product', 'company']], + 'modifiers' => ['{{ product | upper }}', ['product']], + 'conditionals' => ['{{ if company }}{{ product }}{{ /if }}', ['if', 'company', 'product']], + 'nested' => ['{{ products:0:name }}', ['products', '0', 'name']], + 'no fields' => ['Just a static title', []], + ]; + } + #[Test] public function it_gets_preview_targets() { diff --git a/tests/Feature/Entries/EntryTitleFormatTest.php b/tests/Feature/Entries/EntryTitleFormatTest.php index dd4adf29e99..f7dcf72b526 100644 --- a/tests/Feature/Entries/EntryTitleFormatTest.php +++ b/tests/Feature/Entries/EntryTitleFormatTest.php @@ -4,9 +4,15 @@ use Facades\Statamic\Fields\BlueprintRepository; use Facades\Tests\Factories\EntryFactory; +use Illuminate\Support\Facades\Event; use PHPUnit\Framework\Attributes\Test; +use Statamic\Events\EntryCreated; +use Statamic\Events\EntryCreating; +use Statamic\Events\EntrySaved; +use Statamic\Events\EntrySaving; use Statamic\Facades\Blueprint; use Statamic\Facades\Collection; +use Statamic\Facades\Entry; use Statamic\Facades\User; use Tests\FakesRoles; use Tests\PreventSavingStacheItemsToDisk; @@ -30,6 +36,24 @@ public function it_denies_access_if_you_dont_have_permission() ->assertForbidden(); } + #[Test] + public function it_denies_access_when_editing_if_you_can_only_view_entries() + { + $this->setTestRoles(['test' => ['access cp', 'view test entries']]); + $user = tap(User::make()->assignRole('test'))->save(); + $collection = tap(Collection::make('test')->titleFormats('{first_name} {last_name}'))->save(); + + $entry = EntryFactory::collection($collection) + ->slug('michael-aerni') + ->data(['title' => 'Michael Aerni', 'first_name' => 'Michael', 'last_name' => 'Aerni']) + ->create(); + + $this + ->actingAs($user) + ->generateForEdit($entry, ['first_name' => 'Ruth']) + ->assertForbidden(); + } + #[Test] public function it_generates_the_title_when_creating_an_entry() { @@ -117,6 +141,160 @@ public function it_generates_the_title_using_the_submitted_date() ->assertExactJson(['title' => 'Michael Aerni (2023)']); } + #[Test] + public function it_ignores_the_date_when_it_hasnt_been_submitted() + { + [$user, $collection] = $this->seedUserAndCollection(); + $collection->dated(true)->titleFormats('{{ first_name }} ({{ date format="Y" }})')->save(); + $this->seedBlueprintFields($collection, ['first_name' => ['type' => 'text']]); + + $entry = EntryFactory::collection($collection) + ->slug('michael') + ->date('2023-01-18') + ->data(['title' => 'Michael (2023)', 'first_name' => 'Michael']) + ->create(); + + $this + ->actingAs($user) + ->generateForEdit($entry, ['first_name' => 'Ruth']) + ->assertOk() + ->assertExactJson(['title' => 'Ruth (2023)']); + } + + #[Test] + public function it_generates_the_title_using_the_submitted_blueprint() + { + [$user, $collection] = $this->seedUserAndCollection(); + $collection->titleFormats('{first_name} {last_name}')->save(); + + BlueprintRepository::partialMock(); + BlueprintRepository::shouldReceive('in') + ->with('collections/test') + ->andReturn(collect([ + 'first' => Blueprint::makeFromFields(['first_name' => ['type' => 'text']])->setHandle('first'), + 'second' => Blueprint::makeFromFields([ + 'first_name' => ['type' => 'text'], + 'last_name' => ['type' => 'text'], + ])->setHandle('second'), + ])); + + $entry = EntryFactory::collection($collection) + ->blueprint('first') + ->slug('michael') + ->data(['title' => 'Michael', 'first_name' => 'Michael']) + ->create(); + + $this + ->actingAs($user) + ->generateForEdit($entry, ['first_name' => 'Ruth', 'last_name' => 'Aerni'], 'second') + ->assertOk() + ->assertExactJson(['title' => 'Ruth Aerni']); + } + + #[Test] + public function it_generates_the_title_from_the_working_copy() + { + config(['statamic.revisions.enabled' => true]); + + [$user, $collection] = $this->seedUserAndCollection(); + $collection->revisionsEnabled(true)->titleFormats('{first_name} {last_name}')->save(); + $this->seedBlueprintFields($collection, [ + 'first_name' => ['type' => 'text'], + 'last_name' => ['type' => 'text'], + ]); + + $entry = EntryFactory::collection($collection) + ->slug('michael-aerni') + ->data(['title' => 'Michael Aerni', 'first_name' => 'Michael', 'last_name' => 'Aerni']) + ->create(); + + tap($entry->makeWorkingCopy(), function ($copy) { + $attrs = $copy->attributes(); + $attrs['data']['last_name'] = 'Muster'; + $copy->attributes($attrs); + })->save(); + + $this + ->actingAs($user) + ->generateForEdit($entry, ['first_name' => 'Ruth']) + ->assertOk() + ->assertExactJson(['title' => 'Ruth Muster']); + } + + #[Test] + public function it_never_persists_anything() + { + [$user, $collection] = $this->seedUserAndCollection(); + $collection->titleFormats('{first_name} {last_name}')->save(); + $this->seedBlueprintFields($collection, [ + 'first_name' => ['type' => 'text'], + 'last_name' => ['type' => 'text'], + ]); + + $entry = EntryFactory::collection($collection) + ->slug('michael-aerni') + ->data(['title' => 'Michael Aerni', 'first_name' => 'Michael', 'last_name' => 'Aerni']) + ->create(); + + Event::fake(); + + $this + ->actingAs($user) + ->generateForCreate($collection, ['first_name' => 'Ruth', 'last_name' => 'Muster']) + ->assertOk(); + + $this + ->actingAs($user) + ->generateForEdit($entry, ['first_name' => 'Ruth', 'last_name' => 'Muster']) + ->assertOk(); + + Event::assertNotDispatched(EntryCreating::class); + Event::assertNotDispatched(EntryCreated::class); + Event::assertNotDispatched(EntrySaving::class); + Event::assertNotDispatched(EntrySaved::class); + + $this->assertCount(1, Entry::all()); + $this->assertEquals('Michael Aerni', $entry->fresh()->value('title')); + } + + #[Test] + public function the_edit_form_gets_the_endpoint_and_the_fields_the_format_references() + { + [$user, $collection] = $this->seedUserAndCollection(); + $collection->titleFormats('{first_name} {last_name}')->save(); + + $entry = EntryFactory::collection($collection) + ->slug('michael-aerni') + ->data(['title' => 'Michael Aerni', 'first_name' => 'Michael', 'last_name' => 'Aerni']) + ->create(); + + $this + ->actingAs($user) + ->getJson($entry->editUrl()) + ->assertOk() + ->assertJsonPath('titleFormat.url', cp_route('collections.entries.title-format.edit', ['test', $entry->id()])) + ->assertJsonPath('titleFormat.fields', ['first_name', 'last_name']); + } + + #[Test] + public function the_edit_form_doesnt_get_the_endpoint_if_you_can_only_view_entries() + { + $this->setTestRoles(['test' => ['access cp', 'view test entries']]); + $user = tap(User::make()->assignRole('test'))->save(); + $collection = tap(Collection::make('test')->titleFormats('{first_name} {last_name}'))->save(); + + $entry = EntryFactory::collection($collection) + ->slug('michael-aerni') + ->data(['title' => 'Michael Aerni', 'first_name' => 'Michael', 'last_name' => 'Aerni']) + ->create(); + + $this + ->actingAs($user) + ->getJson($entry->editUrl()) + ->assertOk() + ->assertJsonPath('titleFormat', null); + } + #[Test] public function it_404s_when_the_collection_doesnt_generate_titles() { @@ -161,11 +339,11 @@ private function generateForCreate($collection, $values) ); } - private function generateForEdit($entry, $values) + private function generateForEdit($entry, $values, $blueprint = null) { return $this->postJson( cp_route('collections.entries.title-format.edit', [$entry->collectionHandle(), $entry->id()]), - ['values' => $values] + ['blueprint' => $blueprint, 'values' => $values] ); } } diff --git a/tests/Feature/Entries/StoreEntryTest.php b/tests/Feature/Entries/StoreEntryTest.php index 3216bcf6049..e3a94441ab1 100644 --- a/tests/Feature/Entries/StoreEntryTest.php +++ b/tests/Feature/Entries/StoreEntryTest.php @@ -177,6 +177,70 @@ public function submitted_slug_is_favored_over_auto_generated_title_when_using_t $this->assertEquals('manually-entered-slug.md', pathinfo($entry->path(), PATHINFO_BASENAME)); } + #[Test] + public function submitted_title_is_ignored_when_generating_the_slug_from_a_title_format() + { + [$user, $collection] = $this->seedUserAndCollection(); + $collection->titleFormats('Auto {foo}')->save(); + $this->seedBlueprintFields($collection, ['foo' => ['type' => 'text']]); + + $this + ->actingAs($user) + ->submit($collection, [ + 'title' => 'Auto stale', + 'slug' => '', + 'foo' => 'bar', + ])->assertOk(); + + $entry = Entry::all()->first(); + $this->assertEquals('Auto bar', $entry->value('title')); + $this->assertEquals('auto-bar', $entry->slug()); + } + + #[Test] + public function submitted_slug_is_ignored_when_it_is_still_being_auto_generated() + { + // The browser generates the slug asynchronously, so what it submits can lag + // behind the values it was generated from. We regenerate it here instead. + + [$user, $collection] = $this->seedUserAndCollection(); + + $this + ->actingAs($user) + ->submit($collection, [ + 'title' => 'Michael Aerni', + 'slug' => 'michael', + '_auto_slug' => true, + ])->assertOk(); + + $this->assertEquals('michael-aerni', Entry::all()->first()->slug()); + } + + #[Test] + public function submitted_title_and_slug_are_ignored_when_using_title_format_and_the_slug_is_still_being_auto_generated() + { + [$user, $collection] = $this->seedUserAndCollection(); + $collection->titleFormats('{first_name} {last_name}')->save(); + $this->seedBlueprintFields($collection, [ + 'first_name' => ['type' => 'text'], + 'last_name' => ['type' => 'text'], + ]); + + $this + ->actingAs($user) + ->submit($collection, [ + 'title' => 'Michael', + 'slug' => 'michael', + 'first_name' => 'Michael', + 'last_name' => 'Aerni', + '_auto_slug' => true, + ])->assertOk(); + + $entry = Entry::all()->first(); + $this->assertEquals('Michael Aerni', $entry->value('title')); + $this->assertEquals('michael-aerni', $entry->slug()); + } + #[Test] public function slug_and_auto_title_get_generated_after_save() { diff --git a/tests/Feature/Entries/UpdateEntryTest.php b/tests/Feature/Entries/UpdateEntryTest.php index 12598807700..0d66cabbf37 100644 --- a/tests/Feature/Entries/UpdateEntryTest.php +++ b/tests/Feature/Entries/UpdateEntryTest.php @@ -306,6 +306,53 @@ public function submitted_slug_is_favored_over_auto_generated_title_when_using_t $this->assertEquals('manually-entered-slug.md', pathinfo($entry->path(), PATHINFO_BASENAME)); } + #[Test] + public function submitted_title_is_ignored_when_generating_the_slug_from_a_title_format() + { + [$user, $collection] = $this->seedUserAndCollection(); + $collection->titleFormats('Auto {foo}')->save(); + $this->seedBlueprintFields($collection, ['foo' => ['type' => 'text']]); + + $entry = EntryFactory::collection($collection) + ->slug('existing-entry') + ->data(['title' => 'Existing Entry', 'foo' => 'bar']) + ->create(); + + $this + ->actingAs($user) + ->update($entry, ['title' => 'Auto stale', 'slug' => '', 'foo' => 'baz']) + ->assertOk(); + + $entry = $entry->fresh(); + $this->assertEquals('Auto baz', $entry->value('title')); + $this->assertEquals('auto-baz', $entry->slug()); + } + + #[Test] + public function submitted_slug_is_ignored_when_it_is_still_being_auto_generated() + { + // The browser generates the slug asynchronously, so what it submits can lag + // behind the values it was generated from. We regenerate it here instead. + + [$user, $collection] = $this->seedUserAndCollection(); + $collection->titleFormats('Auto {foo}')->save(); + $this->seedBlueprintFields($collection, ['foo' => ['type' => 'text']]); + + $entry = EntryFactory::collection($collection) + ->slug('existing-entry') + ->data(['title' => 'Existing Entry', 'foo' => 'bar']) + ->create(); + + $this + ->actingAs($user) + ->update($entry, ['title' => 'Auto bar', 'slug' => 'auto-bar', 'foo' => 'baz', '_auto_slug' => true]) + ->assertOk(); + + $entry = $entry->fresh(); + $this->assertEquals('Auto baz', $entry->value('title')); + $this->assertEquals('auto-baz', $entry->slug()); + } + #[Test] public function slug_and_auto_title_get_generated_after_save() { From f27820b393bcd8771fad4138d45ebf0cb09f58bb Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 13 Aug 2026 10:56:53 +0100 Subject: [PATCH 4/4] wip --- .../Controllers/CP/Collections/EntryTitleFormatController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php b/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php index 45f134706d8..96f1bdddfbe 100644 --- a/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php +++ b/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php @@ -9,6 +9,8 @@ use Statamic\Http\Controllers\CP\CpController; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use function Statamic\trans as __; + class EntryTitleFormatController extends CpController { public function create(Request $request, $collection, $site)