diff --git a/assets/js/app/editor/Components/Slug.vue b/assets/js/app/editor/Components/Slug.vue
index b7b0857608..a508bfc504 100644
--- a/assets/js/app/editor/Components/Slug.vue
+++ b/assets/js/app/editor/Components/Slug.vue
@@ -53,6 +53,7 @@
diff --git a/assets/js/app/eventBus.ts b/assets/js/app/eventBus.ts
new file mode 100644
index 0000000000..6e0ff60ee5
--- /dev/null
+++ b/assets/js/app/eventBus.ts
@@ -0,0 +1,8 @@
+import mitt from 'mitt';
+
+export type Events = {
+ 'slugify-from-title': void;
+ 'generate-from-title': boolean;
+};
+
+export const eventBus = mitt();
diff --git a/package-lock.json b/package-lock.json
index 5a6cb22d23..cef981a9c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,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",
@@ -14025,7 +14026,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
- "dev": true,
"license": "MIT"
},
"node_modules/ms": {
diff --git a/package.json b/package.json
index fb0653e72e..c0e3d7926b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/tests/unit/README.md b/tests/unit/README.md
index f0d4e7970a..f2711643cf 100644
--- a/tests/unit/README.md
+++ b/tests/unit/README.md
@@ -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 |
diff --git a/tests/unit/components/Slug.spec.ts b/tests/unit/components/Slug.spec.ts
index a903cd8a32..e438e29cf3 100644
--- a/tests/unit/components/Slug.spec.ts
+++ b/tests/unit/components/Slug.spec.ts
@@ -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',
@@ -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');
@@ -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 () => {
@@ -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');
});
diff --git a/tests/unit/components/SlugTextIntegration.spec.ts b/tests/unit/components/SlugTextIntegration.spec.ts
new file mode 100644
index 0000000000..36954bfa53
--- /dev/null
+++ b/tests/unit/components/SlugTextIntegration.spec.ts
@@ -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 = {}) =>
+ mount(Slug, { propsData: { ...slugProps, ...props }, attachTo: document.body });
+
+const mountPair = (slug: Record = {}) => ({
+ text: mountText('fields[title]'),
+ slug: mountSlug(slug),
+});
+
+const slugValue = (slug: ReturnType) => (slug.find('input').element as HTMLInputElement).value;
+
+const clickItem = (slug: ReturnType, 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');
+ });
+});
diff --git a/tests/unit/components/Text.spec.ts b/tests/unit/components/Text.spec.ts
index 04e3c4e2c5..af9ec3d28f 100644
--- a/tests/unit/components/Text.spec.ts
+++ b/tests/unit/components/Text.spec.ts
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { describe, expect, it } from 'vitest';
import Text from '@/editor/Components/Text.vue';
-import { emitOnBus, recordBus } from '../helpers/bus';
+import { busListeners, emitOnBus, recordBus } from '../helpers/bus';
const defaultProps = {
value: 'Initial text',
@@ -18,10 +18,10 @@ const defaultProps = {
autofocus: false,
};
-// Slug and Text talk to each other over the root instance's event bus, which a
-// Slug field elsewhere on the page would normally drive.
-const startGenerating = async (wrapper: ReturnType) => {
- emitOnBus(wrapper, 'generate-from-title', true);
+// Slug and Text talk to each other over the event bus, which a Slug field
+// elsewhere on the page would normally drive.
+const startGenerating = async () => {
+ emitOnBus('generate-from-title', true);
await nextTick();
};
@@ -88,7 +88,7 @@ describe('EditorText', () => {
it('stays quiet on the bus until slug generation is switched on', async () => {
const wrapper = mount(Text, { propsData: defaultProps });
- const asked = recordBus(wrapper, 'slugify-from-title');
+ const asked = recordBus('slugify-from-title');
await nextTick();
await wrapper.find('input').setValue('New title');
@@ -98,9 +98,9 @@ describe('EditorText', () => {
it('asks for a new slug on every keystroke once generation is on', async () => {
const wrapper = mount(Text, { propsData: defaultProps });
- const asked = recordBus(wrapper, 'slugify-from-title');
+ const asked = recordBus('slugify-from-title');
await nextTick();
- await startGenerating(wrapper);
+ await startGenerating();
await wrapper.find('input').setValue('New title');
await wrapper.find('input').setValue('New title 2');
@@ -110,18 +110,27 @@ describe('EditorText', () => {
it('stops asking when generation is switched off again', async () => {
const wrapper = mount(Text, { propsData: defaultProps });
- const asked = recordBus(wrapper, 'slugify-from-title');
+ const asked = recordBus('slugify-from-title');
await nextTick();
- await startGenerating(wrapper);
+ await startGenerating();
await wrapper.find('input').setValue('New title');
- emitOnBus(wrapper, 'generate-from-title', false);
+ emitOnBus('generate-from-title', false);
await nextTick();
await wrapper.find('input').setValue('New title 2');
expect(asked).toHaveLength(1);
});
+ it('stops listening on the bus once destroyed', () => {
+ const wrapper = mount(Text, { propsData: defaultProps });
+ expect(busListeners('generate-from-title')).toBe(1);
+
+ wrapper.destroy();
+
+ expect(busListeners('generate-from-title')).toBe(0);
+ });
+
it.skip('accepts a numeric value and renders it as text', () => {
// Only passes after 7450e402 ("Keep media crop values numeric") widens
// the `value` prop to accept a Number. Today a numeric value trips
@@ -131,9 +140,8 @@ describe('EditorText', () => {
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('50');
});
- // Only passes on refactor/vue3-composition-api, where the mitt event bus
- // carries `{ sources, active }` / `{ source }` payloads. The Vue 2.7
- // $root bus broadcasts a bare boolean, so every Text field on the page
- // drives every Slug field.
+ // Only passes once the bus carries `{ sources, active }` / `{ source }`
+ // payloads, as on refactor/vue3-composition-api. Today it broadcasts a
+ // bare boolean, so every Text field on the page drives every Slug field.
it.todo('only regenerates the slug it is scoped to');
});
diff --git a/tests/unit/helpers/bus.ts b/tests/unit/helpers/bus.ts
index 86100d1ad8..7eb637a26b 100644
--- a/tests/unit/helpers/bus.ts
+++ b/tests/unit/helpers/bus.ts
@@ -1,31 +1,29 @@
-import type Vue from 'vue';
+import type { Emitter } from 'mitt';
+import { eventBus, type Events } from '@/eventBus';
/**
* The Slug ⇄ Text event bus.
*
- * Vue 2.7 has no dedicated bus: Slug.vue and Text.vue talk over `this.$root`,
- * which every component on the page shares. Note that test-utils mounts the
- * component under test inside a parent, so `$root` is that parent rather than
- * the component — which means `wrapper.emitted()` does *not* see bus traffic
- * and it has to be observed here instead.
- *
- * `$root.$emit`/`$on` is removed in Vue 3; the upgrade replaces it with a mitt
- * instance, at which point only this file changes.
+ * Slug.vue and Text.vue talk over the mitt bus in assets/js/app/eventBus.ts.
+ * It is a module singleton shared by every component, so `wrapper.emitted()`
+ * does *not* see its traffic and it has to be observed here instead. The
+ * global teardown in setup.ts clears its handlers after each test.
*/
-type Wrapper = { vm: Vue };
-
/** Emits on the shared bus, as another field on the page would. */
-export function emitOnBus(wrapper: Wrapper, event: string, ...payload: unknown[]): void {
- wrapper.vm.$root.$emit(event, ...payload);
-}
+export const emitOnBus: Emitter['emit'] = eventBus.emit;
/**
- * Starts recording one bus event. The returned array collects the arguments of
- * each emission, in order — the same shape as `wrapper.emitted(event)`.
+ * Starts recording one bus event. The returned array collects the payload of
+ * each emission, in order.
*/
-export function recordBus(wrapper: Wrapper, event: string): unknown[][] {
- const emissions: unknown[][] = [];
- wrapper.vm.$root.$on(event, (...args: unknown[]) => emissions.push(args));
+export function recordBus(event: Key): Events[Key][] {
+ const emissions: Events[Key][] = [];
+ eventBus.on(event, payload => emissions.push(payload));
return emissions;
}
+
+/** How many handlers are listening for one bus event. */
+export function busListeners(event: keyof Events): number {
+ return eventBus.all.get(event)?.length ?? 0;
+}
diff --git a/tests/unit/setup.ts b/tests/unit/setup.ts
index 49e31e974a..f4225c1dfb 100644
--- a/tests/unit/setup.ts
+++ b/tests/unit/setup.ts
@@ -2,6 +2,7 @@ import Vue from 'vue';
import { enableAutoDestroy } from '@vue/test-utils';
import $ from 'jquery';
import { afterEach, beforeEach, expect, vi } from 'vitest';
+import { eventBus } from '@/eventBus';
import { resetDom } from './helpers/dom';
import { installWarningHandler, resetWarnings, reviewWarnings } from './helpers/warnings';
@@ -64,6 +65,10 @@ afterEach(() => {
delete (window as unknown as Record).selectCache;
delete (window as unknown as Record).requestCache;
+ // The event bus is a module singleton, so recordBus() listeners would
+ // otherwise outlive their test.
+ eventBus.all.clear();
+
expect(unexpected, `Unexpected Vue warning(s):\n${unexpected.join('\n')}`).toEqual([]);
expect(missing, `Expected Vue warning(s) that never happened:\n${missing.join('\n')}`).toEqual([]);
});