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
44 changes: 43 additions & 1 deletion resources/js/components/entries/PublishForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions resources/js/components/fieldtypes/SlugFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions resources/js/pages/entries/Create.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Head from '@/pages/layout/Head.vue';

defineProps([
'actions',
'titleFormat',
'collection',
'collectionCreateLabel',
'blueprint',
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions resources/js/pages/entries/Edit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Head from '@/pages/layout/Head.vue';

defineProps([
'actions',
'titleFormat',
'collection',
'title',
'reference',
Expand Down Expand Up @@ -39,6 +40,7 @@ defineProps([
<EntryPublishForm
publish-container="base"
:initial-actions="actions"
:initial-title-format="titleFormat"
method="patch"
:collection-handle="collection"
:initial-title="title"
Expand Down
111 changes: 111 additions & 0 deletions resources/js/tests/components/entries/TitleFormat.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { shallowMount } from '@vue/test-utils';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import PublishForm from '@/components/entries/PublishForm.vue';

window.__ = (key) => 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();
});
51 changes: 51 additions & 0 deletions resources/js/tests/components/fieldtypes/SlugFieldtype.test.js
Original file line number Diff line number Diff line change
@@ -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 }]);
});
3 changes: 3 additions & 0 deletions routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');

Expand All @@ -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()));
});
Expand Down
26 changes: 19 additions & 7 deletions src/Entries/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
Loading
Loading