diff --git a/resources/js/components/entries/PublishForm.vue b/resources/js/components/entries/PublishForm.vue index 2c888064b35..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" @@ -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'; @@ -352,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, @@ -564,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, @@ -629,6 +633,38 @@ export default { } }, + generateTitle() { + if (!this.titleFormat) return; + + 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.titleFormat.url, + { 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 fields = this.titleFormat.fields.filter((field) => field in this.values); + + return Object.fromEntries(fields.map((field) => [field, this.values[field]])); + }, + localizationSelected(localization) { if (!this.canSave) { if (localization.exists) this.editLocalization(localization); @@ -685,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; @@ -877,6 +914,11 @@ export default { created() { window.history.replaceState({}, document.title, document.location.href.replace('created=true', '')); + if (this.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 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/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/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 acd8b573d60..32168c80e3a 100644 --- a/src/Http/Controllers/CP/Collections/EntriesController.php +++ b/src/Http/Controllers/CP/Collections/EntriesController.php @@ -131,6 +131,10 @@ public function edit(Request $request, $collection, $entry) 'createRevision' => $entry->createRevisionUrl(), 'editBlueprint' => cp_route('blueprints.collections.edit', [$collection, $blueprint]), ], + '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, @@ -291,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(); @@ -325,6 +329,10 @@ public function create(Request $request, $collection, $site) 'save' => cp_route('collections.entries.store', [$collection->handle(), $site->handle()]), 'editBlueprint' => cp_route('blueprints.collections.edit', [$collection, $blueprint]), ], + '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, @@ -445,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 new file mode 100644 index 00000000000..96f1bdddfbe --- /dev/null +++ b/src/Http/Controllers/CP/Collections/EntryTitleFormatController.php @@ -0,0 +1,85 @@ +authorize('create', [EntryContract::class, $collection, $site]); + + $this->ensureCollectionAutoGeneratesTitles($collection); + + $blueprint = $collection->entryBlueprint($request->blueprint); + + if (! $blueprint) { + throw new \Exception(__('A valid blueprint is required.')); + } + + $entry = Entry::make() + ->collection($collection) + ->blueprint($blueprint->handle()) + ->locale($site->handle()); + + $values = $this->processedValues($blueprint, $request); + + if ($collection->dated() && $values->has('date')) { + $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(); + + 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'))); + } + + return ['title' => $entry->merge($values->except(['title', 'slug']))->autoGeneratedTitle()]; + } + + private function ensureCollectionAutoGeneratesTitles($collection) + { + if (! $collection->autoGeneratesTitles()) { + throw new NotFoundHttpException; + } + } + + 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($values) + ->process() + ->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 new file mode 100644 index 00000000000..f7dcf72b526 --- /dev/null +++ b/tests/Feature/Entries/EntryTitleFormatTest.php @@ -0,0 +1,349 @@ +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_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() + { + [$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_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() + { + [$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, $blueprint = null) + { + return $this->postJson( + cp_route('collections.entries.title-format.edit', [$entry->collectionHandle(), $entry->id()]), + ['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() {