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([]); });