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
14 changes: 9 additions & 5 deletions assets/js/app/editor/Components/Slug.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
<script>
import field from '../mixins/value';
import { slugify } from '../../../filters/string';
import { eventBus } from '../../eventBus';

export default {
name: 'EditorSlug',
Expand Down Expand Up @@ -88,25 +89,28 @@ export default {
if (this.shouldGenerateFromTitle(title)) {
this.icon = 'unlock';
this.buttonText = this.$props.labels.button_unlocked;
this.$root.$emit('generate-from-title', true);
eventBus.emit('generate-from-title', true);
this.generateSlug();
}
}, 0);
this.$root.$on('slugify-from-title', () => this.generateSlug());
eventBus.on('slugify-from-title', this.generateSlug);
},
beforeDestroy() {
eventBus.off('slugify-from-title', this.generateSlug);
},
methods: {
shouldGenerateFromTitle(title) {
return this.isNew ? true : title.length <= 0 && this.localize;
},
editSlug() {
this.$root.$emit('generate-from-title', false);
eventBus.emit('generate-from-title', false);
this.edit = true;
this.locked = false;
this.buttonText = this.$props.labels.button_edit;
this.icon = 'pencil-alt';
},
lockSlug() {
this.$root.$emit('generate-from-title', false);
eventBus.emit('generate-from-title', false);
const slug = slugify(this.val);
this.val = slug;
this.edit = false;
Expand All @@ -122,7 +126,7 @@ export default {

const slug = slugify(title);
this.val = slug;
this.$root.$emit('generate-from-title', true);
eventBus.emit('generate-from-title', true);

this.edit = false;
this.locked = false;
Expand Down
13 changes: 11 additions & 2 deletions assets/js/app/editor/Components/Text.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

<script>
import field from '../mixins/value';
import { eventBus } from '../../eventBus';

export default {
name: 'EditorText',
Expand Down Expand Up @@ -55,12 +56,20 @@ export default {
watch: {
rawVal() {
if (this.generate) {
this.$root.$emit('slugify-from-title');
eventBus.emit('slugify-from-title');
}
},
},
mounted() {
this.$root.$on('generate-from-title', data => (this.generate = data));
eventBus.on('generate-from-title', this.setGenerate);
},
beforeDestroy() {
eventBus.off('generate-from-title', this.setGenerate);
},
methods: {
setGenerate(active) {
this.generate = active;
},
},
};
</script>
8 changes: 8 additions & 0 deletions assets/js/app/eventBus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import mitt from 'mitt';

export type Events = {
'slugify-from-title': void;
'generate-from-title': boolean;
};

export const eventBus = mitt<Events>();
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"jquery": "^3.7.1",
"locutus": "^3.0.25",
"luxon": "^1.28.1",
"mitt": "^3.0.1",
"no-scroll": "^2.1.1",
"node-vibrant": "^4.0.4",
"popper.js": "^1.16.1",
Expand Down
1 change: 0 additions & 1 deletion tests/unit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ layer, so the upgrade edits them. Most of it is mechanical:
| `stubs:` / `localVue` / `store:` → `global: { stubs, plugins }` | every mount that has them |
| `wrapper.destroy()` → `unmount()`, `findAll(...).at(i)` → `[i]` | mechanical |
| Vuex → Pinia | `helpers/store.ts` and the specs that seed state |
| `$root.$emit`/`$on` → the mitt bus | `helpers/bus.ts`, plus Text and Slug |
| vue-multiselect 2→3, vuedraggable 2→4, trumbowyg 3→4, easymde | `helpers/stubs.ts` |
| `flushPromises` | delete `helpers/async.ts`, import from test-utils |

Expand Down
30 changes: 20 additions & 10 deletions tests/unit/components/Slug.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
import Slug from '@/editor/Components/Slug.vue';
import { withTitleFields } from '../helpers/dom';
import { flushPromises } from '../helpers/async';
import { emitOnBus, recordBus } from '../helpers/bus';
import { busListeners, emitOnBus, recordBus } from '../helpers/bus';

const labels = {
button_locked: 'Locked',
Expand Down Expand Up @@ -174,7 +174,7 @@ describe('EditorSlug', () => {
const wrapper = mountSlug();
await settle();

emitOnBus(wrapper, 'slugify-from-title');
emitOnBus('slugify-from-title');
await nextTick();

expect(input(wrapper).value).toBe('a-fresh-title');
Expand All @@ -183,23 +183,34 @@ describe('EditorSlug', () => {
it('tells the title fields to start driving the slug once it generates', async () => {
withTitleFields({ title: 'A Fresh Title' });
const wrapper = mountSlug();
const generating = recordBus(wrapper, 'generate-from-title');
const generating = recordBus('generate-from-title');
await settle();

await clickItem(wrapper, 'Generate from title');

expect(generating).toEqual([[true]]);
expect(generating).toEqual([true]);
});

it('tells the title fields to stop driving the slug once it is edited by hand', async () => {
withTitleFields({ title: 'A page' });
const wrapper = mountSlug();
const generating = recordBus(wrapper, 'generate-from-title');
const generating = recordBus('generate-from-title');
await settle();

await clickItem(wrapper, 'Edit');

expect(generating).toEqual([[false]]);
expect(generating).toEqual([false]);
});

it('stops listening on the bus once destroyed', async () => {
withTitleFields({ title: 'A page' });
const wrapper = mountSlug();
await settle();
expect(busListeners('slugify-from-title')).toBe(1);

wrapper.destroy();

expect(busListeners('slugify-from-title')).toBe(0);
});

it('disables the dropdown when the field is read-only', async () => {
Expand All @@ -223,9 +234,8 @@ describe('EditorSlug', () => {
expect(() => vi.runAllTimers()).toThrow(TypeError);
});

// Only passes on refactor/vue3-composition-api, where the mitt event bus
// carries a `{ source }` payload. The Vue 2.7 $root bus broadcasts
// 'slugify-from-title' with no payload, so every Slug on the page
// regenerates whenever any Text field changes — see 3778f430.
// Only passes once 'slugify-from-title' carries a `{ source }` payload, as
// on refactor/vue3-composition-api. Today it has none, so every Slug on the
// page regenerates whenever any Text field changes.
it.todo('only responds to the title fields it is scoped to');
});
149 changes: 149 additions & 0 deletions tests/unit/components/SlugTextIntegration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { describe, expect, it } from 'vitest';
import Slug from '@/editor/Components/Slug.vue';
import Text from '@/editor/Components/Text.vue';
import { flushPromises } from '../helpers/async';

/**
* The Slug ⇄ Text auto-generation flow end to end, over the real event bus
* rather than through helpers/bus.ts.
*
* Both are attached to the document: Slug reads its source fields straight off
* it, by the same `fields[...]` name the Text field renders.
*/

const slugProps = {
value: '',
name: 'fields[slug]',
generate: 'title',
labels: {
button_edit: 'Edit',
button_locked: 'Locked',
button_unlocked: 'Unlocked',
generate_from: 'Generate from',
},
required: false,
readonly: false,
errormessage: false,
pattern: false,
localize: false,
isNew: true,
};

const mountText = (name: string, value = '') => mount(Text, { propsData: { value, name }, attachTo: document.body });

const mountSlug = (props: Record<string, unknown> = {}) =>
mount(Slug, { propsData: { ...slugProps, ...props }, attachTo: document.body });

const mountPair = (slug: Record<string, unknown> = {}) => ({
text: mountText('fields[title]'),
slug: mountSlug(slug),
});

const slugValue = (slug: ReturnType<typeof mount>) => (slug.find('input').element as HTMLInputElement).value;

const clickItem = (slug: ReturnType<typeof mount>, label: string) => {
const item = slug.findAll('.dropdown-item').wrappers.find(entry => entry.text().trim() === label);
if (!item) {
throw new Error(`No "${label}" action`);
}
return item.trigger('click');
};

// Slug's mounted() defers its work into a setTimeout.
const settle = async () => {
await flushPromises();
await nextTick();
};

describe('Slug and Text together', () => {
it('regenerates a new record’s slug as its title is typed', async () => {
const { text, slug } = mountPair();
await settle();

await text.find('input').setValue('Hello World');

expect(slugValue(slug)).toBe('hello-world');
});

it('joins every source field into the slug', async () => {
mountText('fields[title]', 'Hello');
const subtitle = mountText('fields[subtitle]');
const slug = mountSlug({ generate: 'title,subtitle' });
await settle();

await subtitle.find('input').setValue('World');

expect(slugValue(slug)).toBe('hello-world');
});

it('leaves an existing record’s slug alone when its title is typed', async () => {
const { text, slug } = mountPair({ value: 'existing-slug', isNew: false });
await settle();

await text.find('input').setValue('Hello World');

expect(slugValue(slug)).toBe('existing-slug');
});

it('follows the title once an existing record’s slug is generated from it', async () => {
const { text, slug } = mountPair({ value: 'existing-slug', isNew: false });
await settle();

await clickItem(slug, 'Generate from title');
await text.find('input').setValue('Hello World');

expect(slugValue(slug)).toBe('hello-world');
});

it.each(['Edit', 'Locked'])('stops following the title after "%s"', async action => {
const { text, slug } = mountPair();
await settle();
await text.find('input').setValue('Hello');

await clickItem(slug, action);
await text.find('input').setValue('Hello World');

expect(slugValue(slug)).toBe('hello');
});

it('follows the title again once a slug edited by hand is generated from it', async () => {
const { text, slug } = mountPair();
await settle();

await clickItem(slug, 'Edit');
await clickItem(slug, 'Generate from title');
await text.find('input').setValue('Hello World');

expect(slugValue(slug)).toBe('hello-world');
});

it('follows the title of a localised record whose title is still empty', async () => {
const { text, slug } = mountPair({ localize: true, isNew: false });
await settle();

await text.find('input').setValue('Hello World');

expect(slugValue(slug)).toBe('hello-world');
});

it('still follows and stops after a text field in a collection is typed in and removed', async () => {
const nested = mountText('collections[collection][set][abc][title]');
const { text, slug } = mountPair();
await settle();

await nested.find('input').setValue('Nested block title');
expect(slugValue(slug)).toBe('');

// As when the collection re-renders its items.
nested.destroy();
await text.find('input').setValue('Hello');
expect(slugValue(slug)).toBe('hello');

await clickItem(slug, 'Edit');
await text.find('input').setValue('Hello World');

expect(slugValue(slug)).toBe('hello');
});
});
Loading
Loading