diff --git a/assets/js/app/editor/Components/Date.vue b/assets/js/app/editor/Components/Date.vue
index 5a9e184d24..8fdf4dbf5a 100644
--- a/assets/js/app/editor/Components/Date.vue
+++ b/assets/js/app/editor/Components/Date.vue
@@ -9,7 +9,7 @@
:form="form"
:name="name"
placeholder="Select date"
- :data-errormessage="errormessage"
+ :data-errormessage="optionalAttribute(errormessage)"
>
@@ -19,6 +19,8 @@
diff --git a/assets/js/app/editor/Components/Embed.vue b/assets/js/app/editor/Components/Embed.vue
index eb9bd8d44b..ac2f93a4e9 100644
--- a/assets/js/app/editor/Components/Embed.vue
+++ b/assets/js/app/editor/Components/Embed.vue
@@ -16,8 +16,8 @@
type="url"
:required="required"
:readonly="readonly"
- :data-errormessage="errormessage"
- :pattern="pattern"
+ :data-errormessage="optionalAttribute(errormessage)"
+ :pattern="optionalAttribute(pattern)"
/>
@@ -30,7 +30,7 @@
type="text"
:required="required"
:readonly="readonly"
- :pattern="pattern"
+ :pattern="optionalAttribute(pattern)"
:placeholder="getPlaceholder"
/>
@@ -149,6 +149,7 @@ import Axios from 'axios';
import { renable } from '../../patience-is-a-virtue';
import { resetModalContent } from '../../modal';
import { Modal } from 'bootstrap';
+import { optionalAttribute } from '../utils/attributes';
export default {
name: 'EditorFile',
@@ -202,6 +203,7 @@ export default {
},
},
methods: {
+ optionalAttribute,
onMoveFileDown() {
this.$emit('move-file-down', this);
},
diff --git a/assets/js/app/editor/Components/Image.vue b/assets/js/app/editor/Components/Image.vue
index 5bd15cee04..652274847b 100644
--- a/assets/js/app/editor/Components/Image.vue
+++ b/assets/js/app/editor/Components/Image.vue
@@ -30,7 +30,7 @@
:value="filenameData"
data-readonly="readonly"
:required="required"
- :data-errormessage="errormessage"
+ :data-errormessage="optionalAttribute(errormessage)"
/>
@@ -45,7 +45,7 @@
:name="name + '[alt]'"
type="text"
:readonly="readonly"
- :pattern="pattern"
+ :pattern="optionalAttribute(pattern)"
:placeholder="getPlaceholder"
/>
@@ -198,6 +198,7 @@ import Axios from 'axios';
import { Modal } from 'bootstrap';
import { renable } from '../../patience-is-a-virtue';
import { resetModalContent } from '../../modal';
+import { optionalAttribute } from '../utils/attributes';
export default {
name: 'EditorImage',
@@ -276,6 +277,7 @@ export default {
});
},
methods: {
+ optionalAttribute,
resetImage() {
if (!this.filenameData) {
this.previewImage = null;
diff --git a/assets/js/app/editor/Components/Number.vue b/assets/js/app/editor/Components/Number.vue
index ed07593e21..b1ca9db06b 100644
--- a/assets/js/app/editor/Components/Number.vue
+++ b/assets/js/app/editor/Components/Number.vue
@@ -11,8 +11,8 @@
:max="max"
:required="required"
:readonly="readonly"
- :data-errormessage="errormessage"
- :pattern="pattern"
+ :data-errormessage="optionalAttribute(errormessage)"
+ :pattern="optionalAttribute(pattern)"
:placeholder="placeholder"
/>
@@ -20,6 +20,7 @@
diff --git a/assets/js/app/editor/Components/Password.vue b/assets/js/app/editor/Components/Password.vue
index 78a5da36ee..ce4b8fb3bf 100644
--- a/assets/js/app/editor/Components/Password.vue
+++ b/assets/js/app/editor/Components/Password.vue
@@ -10,8 +10,8 @@
:value="value"
:required="required"
:readonly="readonly"
- :data-errormessage="errormessage"
- :pattern="pattern"
+ :data-errormessage="optionalAttribute(errormessage)"
+ :pattern="optionalAttribute(pattern)"
:placeholder="placeholder"
autocomplete="new-password"
@input="measureStrength"
@@ -28,6 +28,7 @@
diff --git a/assets/js/app/editor/Components/Textarea.vue b/assets/js/app/editor/Components/Textarea.vue
index 0fb3d20f0f..82878f478d 100644
--- a/assets/js/app/editor/Components/Textarea.vue
+++ b/assets/js/app/editor/Components/Textarea.vue
@@ -8,7 +8,7 @@
:rows="rows"
:required="required"
:readonly="readonly"
- :data-errormessage="errormessage"
+ :data-errormessage="optionalAttribute(errormessage)"
:placeholder="placeholder"
:style="{ height: styleHeight }"
:maxlength="maxlength"
@@ -19,6 +19,7 @@
diff --git a/assets/js/app/editor/utils/attributes.ts b/assets/js/app/editor/utils/attributes.ts
new file mode 100644
index 0000000000..b501112b7c
--- /dev/null
+++ b/assets/js/app/editor/utils/attributes.ts
@@ -0,0 +1,11 @@
+/**
+ * Twig passes `false` for an unset field option, such as a pattern or an error
+ * message. Vue 2 drops a non-boolean attribute bound to `false`, but Vue 3
+ * renders it as "false"; `undefined` drops it in both. Every other value is
+ * returned as it is.
+ *
+ * @see https://v3-migration.vuejs.org/breaking-changes/attribute-coercion.html
+ */
+export function optionalAttribute(value: T | false): T | undefined {
+ return value === false ? undefined : value;
+}
diff --git a/tests/unit/README.md b/tests/unit/README.md
index f0d4e7970a..720e47e338 100644
--- a/tests/unit/README.md
+++ b/tests/unit/README.md
@@ -59,7 +59,7 @@ regression baseline rather than a round of manual clicking.
app/ the non-component scripts (common, ajax-save, modal,
notifications, patience-is-a-virtue, save-on-ctrl-s)
components/ the SFCs, mirroring assets/js/app/
- filters/ services/ store/ mixins/
+ filters/ services/ store/ mixins/ utils/
## Notes for the Vue 3 upgrade
diff --git a/tests/unit/components/Date.spec.ts b/tests/unit/components/Date.spec.ts
index d4c4eade83..73ec858377 100644
--- a/tests/unit/components/Date.spec.ts
+++ b/tests/unit/components/Date.spec.ts
@@ -164,4 +164,13 @@ describe('EditorDate', () => {
// from updated(), so an empty required field can flash the browser's
// "please fill in this field" popup before the user has touched it.
it.todo('does not surface the native validation popup on mount');
+
+ it('hands the error message to the picker only when there is one', async () => {
+ const withMessage = mountDate();
+ const without = mountDate({ errormessage: false });
+ await nextTick();
+
+ expect(submitted(withMessage).attributes('data-errormessage')).toBe('Pick a date');
+ expect(submitted(without).attributes('data-errormessage')).toBeUndefined();
+ });
});
diff --git a/tests/unit/components/Email.spec.ts b/tests/unit/components/Email.spec.ts
index 4188e487b0..da445c7024 100644
--- a/tests/unit/components/Email.spec.ts
+++ b/tests/unit/components/Email.spec.ts
@@ -51,4 +51,14 @@ describe('EditorEmail', () => {
expect(wrapper.find('input').attributes('readonly')).toBeDefined();
});
+
+ it('omits the error message and pattern when they are false', () => {
+ const wrapper = mount(Email, {
+ propsData: { value: '', name: 'fields[email]', errormessage: false, pattern: false },
+ });
+
+ const input = wrapper.find('input');
+ expect(input.attributes('data-errormessage')).toBeUndefined();
+ expect(input.attributes('pattern')).toBeUndefined();
+ });
});
diff --git a/tests/unit/components/Embed.spec.ts b/tests/unit/components/Embed.spec.ts
index afc2923b6a..c1b9744a5b 100644
--- a/tests/unit/components/Embed.spec.ts
+++ b/tests/unit/components/Embed.spec.ts
@@ -294,4 +294,11 @@ describe('EditorEmbed', () => {
expect(wrapper.find('input[name="fields[video][width]"]').attributes('readonly')).toBeDefined();
expect(wrapper.find('button.remove').attributes('disabled')).toBeUndefined();
});
+
+ it('omits the error message and pattern when they are false', () => {
+ const wrapper = mountEmbed({ errormessage: false, pattern: false });
+
+ expect(urlInput(wrapper).attributes('data-errormessage')).toBeUndefined();
+ expect(urlInput(wrapper).attributes('pattern')).toBeUndefined();
+ });
});
diff --git a/tests/unit/components/File.spec.ts b/tests/unit/components/File.spec.ts
index 1531867c8c..f97d18e040 100644
--- a/tests/unit/components/File.spec.ts
+++ b/tests/unit/components/File.spec.ts
@@ -420,9 +420,27 @@ describe('EditorFile', () => {
});
});
- // Only passes after 2f840c0d, which declares the props Twig passes.
- // Until then `placeholder`, `pattern` and friends arrive as undeclared
- // attributes and fall through onto the root element instead of being
- // bound where the template expects them.
- it.todo('omits optional attributes when they are unset or false');
+ it('omits optional attributes when they are unset or false', () => {
+ // Twig passes `false` for an unset pattern, placeholder or error message.
+ const wrapper = mountFile({ pattern: false, placeholder: false, errormessage: false });
+
+ const filename = wrapper.find('input[name="fields[document][filename]"]');
+ const title = wrapper.find('input[name="fields[document][title]"]');
+ expect(filename.attributes('data-errormessage')).toBeUndefined();
+ expect(title.attributes('pattern')).toBeUndefined();
+ expect(title.attributes('placeholder')).toBe(labels.placeholder_title);
+ // Declared as props, so none of them falls through onto the root.
+ expect(wrapper.attributes('pattern')).toBeUndefined();
+ expect(wrapper.attributes('placeholder')).toBeUndefined();
+ });
+
+ it('binds the optional attributes where they apply when set', () => {
+ const wrapper = mountFile({ pattern: '[a-z]+', placeholder: 'Describe it', errormessage: 'Pick a file' });
+
+ const filename = wrapper.find('input[name="fields[document][filename]"]');
+ const title = wrapper.find('input[name="fields[document][title]"]');
+ expect(filename.attributes('data-errormessage')).toBe('Pick a file');
+ expect(title.attributes('pattern')).toBe('[a-z]+');
+ expect(title.attributes('placeholder')).toBe('Describe it');
+ });
});
diff --git a/tests/unit/components/Image.spec.ts b/tests/unit/components/Image.spec.ts
index d38f356b73..9a5c716bb9 100644
--- a/tests/unit/components/Image.spec.ts
+++ b/tests/unit/components/Image.spec.ts
@@ -476,4 +476,20 @@ describe('EditorImage', () => {
// editor/utils/serverFileBrowser.ts and reads the server's message.
// That module does not exist on this branch.
it.todo('surfaces the server message when a browse failure is a real AxiosError');
+
+ it('omits optional attributes when they are unset or false', () => {
+ // Twig passes `false` for an unset pattern, placeholder or error message.
+ const wrapper = mountImage({ pattern: false, placeholder: false, errormessage: false });
+
+ expect(wrapper.find(filenameInput).attributes('data-errormessage')).toBeUndefined();
+ expect(wrapper.find(altInput).attributes('pattern')).toBeUndefined();
+ expect(wrapper.find(altInput).attributes('placeholder')).toBe(labels.placeholder_alt_text);
+ });
+
+ it('binds the optional attributes where they apply when set', () => {
+ const wrapper = mountImage({ pattern: '[a-z ]+', errormessage: 'Pick an image' });
+
+ expect(wrapper.find(filenameInput).attributes('data-errormessage')).toBe('Pick an image');
+ expect(wrapper.find(altInput).attributes('pattern')).toBe('[a-z ]+');
+ });
});
diff --git a/tests/unit/components/Number.spec.ts b/tests/unit/components/Number.spec.ts
index c8a1fb5e97..ecac8e3501 100644
--- a/tests/unit/components/Number.spec.ts
+++ b/tests/unit/components/Number.spec.ts
@@ -16,6 +16,7 @@ describe('EditorNumber', () => {
required: true,
readonly: false,
errormessage: 'Out of range',
+ pattern: '[0-9]+',
placeholder: 'Weight',
},
});
@@ -33,6 +34,7 @@ describe('EditorNumber', () => {
expect(input.attributes('max')).toBe('100');
expect(input.attributes('required')).toBeDefined();
expect(input.attributes('data-errormessage')).toBe('Out of range');
+ expect(input.attributes('pattern')).toBe('[0-9]+');
expect(input.attributes('placeholder')).toBe('Weight');
});
@@ -63,4 +65,14 @@ describe('EditorNumber', () => {
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('99');
});
+
+ it('omits the error message and pattern when they are false', () => {
+ const wrapper = mount(NumberField, {
+ propsData: { value: '1', name: 'fields[weight]', errormessage: false, pattern: false },
+ });
+
+ const input = wrapper.find('input');
+ expect(input.attributes('data-errormessage')).toBeUndefined();
+ expect(input.attributes('pattern')).toBeUndefined();
+ });
});
diff --git a/tests/unit/components/Password.spec.ts b/tests/unit/components/Password.spec.ts
index 0bcffec72f..3dd5087438 100644
--- a/tests/unit/components/Password.spec.ts
+++ b/tests/unit/components/Password.spec.ts
@@ -126,4 +126,11 @@ describe('EditorPassword', () => {
expect(zxcvbn).not.toHaveBeenCalled();
});
+
+ it('omits the error message and pattern when they are false', () => {
+ const wrapper = mountPassword({ errormessage: false, pattern: false });
+
+ expect(input(wrapper).attributes('data-errormessage')).toBeUndefined();
+ expect(input(wrapper).attributes('pattern')).toBeUndefined();
+ });
});
diff --git a/tests/unit/components/Select.spec.ts b/tests/unit/components/Select.spec.ts
index 188434762c..25fd6b1fb5 100644
--- a/tests/unit/components/Select.spec.ts
+++ b/tests/unit/components/Select.spec.ts
@@ -285,4 +285,13 @@ describe('EditorSelect', () => {
// Only passes after 8d54e750. addTag pushes unconditionally, so tagging
// a single-select field ends up with two selected items.
it.todo('replaces the selection when tagging in single-select mode');
+
+ it('hands the error message to the picker only when there is one', async () => {
+ const withMessage = mountSelect();
+ const without = mountSelect({ errormessage: false });
+ await nextTick();
+
+ expect(picker(withMessage).attributes('data-errormessage')).toBe('Pick one');
+ expect(picker(without).attributes('data-errormessage')).toBeUndefined();
+ });
});
diff --git a/tests/unit/components/Slug.spec.ts b/tests/unit/components/Slug.spec.ts
index a903cd8a32..2db40be5ab 100644
--- a/tests/unit/components/Slug.spec.ts
+++ b/tests/unit/components/Slug.spec.ts
@@ -228,4 +228,13 @@ describe('EditorSlug', () => {
// 'slugify-from-title' with no payload, so every Slug on the page
// regenerates whenever any Text field changes — see 3778f430.
it.todo('only responds to the title fields it is scoped to');
+
+ it('omits the error message and pattern when they are false', async () => {
+ withTitleFields({ title: 'A page' });
+ const wrapper = mountSlug({ errormessage: false, pattern: false });
+ await settle();
+
+ expect(wrapper.find('input').attributes('data-errormessage')).toBeUndefined();
+ expect(wrapper.find('input').attributes('pattern')).toBeUndefined();
+ });
});
diff --git a/tests/unit/components/Text.spec.ts b/tests/unit/components/Text.spec.ts
index 04e3c4e2c5..2c240ce4fc 100644
--- a/tests/unit/components/Text.spec.ts
+++ b/tests/unit/components/Text.spec.ts
@@ -136,4 +136,12 @@ describe('EditorText', () => {
// $root bus 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');
+
+ it('omits the error message and pattern when they are false', () => {
+ const wrapper = mount(Text, { propsData: { ...defaultProps, errormessage: false, pattern: false } });
+
+ const input = wrapper.find('input');
+ expect(input.attributes('data-errormessage')).toBeUndefined();
+ expect(input.attributes('pattern')).toBeUndefined();
+ });
});
diff --git a/tests/unit/components/Textarea.spec.ts b/tests/unit/components/Textarea.spec.ts
index b9c98de0d2..7c6fa1212c 100644
--- a/tests/unit/components/Textarea.spec.ts
+++ b/tests/unit/components/Textarea.spec.ts
@@ -75,4 +75,11 @@ describe('EditorTextarea', () => {
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('Edited');
});
+
+ it('omits the error message when it is false', () => {
+ const wrapper = mount(Textarea, { propsData: { ...defaultProps, errormessage: false } });
+
+ const textarea = wrapper.find('textarea');
+ expect(textarea.attributes('data-errormessage')).toBeUndefined();
+ });
});
diff --git a/tests/unit/utils/attributes.spec.ts b/tests/unit/utils/attributes.spec.ts
new file mode 100644
index 0000000000..376d1afe1e
--- /dev/null
+++ b/tests/unit/utils/attributes.spec.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest';
+import { optionalAttribute } from '@/editor/utils/attributes';
+
+describe('optionalAttribute', () => {
+ it('turns false into undefined, which drops the attribute in Vue 2 and Vue 3 alike', () => {
+ expect(optionalAttribute(false)).toBeUndefined();
+ });
+
+ it.each(['[a-z]+', '', 0, null, undefined])('leaves %j as it is', value => {
+ expect(optionalAttribute(value)).toBe(value);
+ });
+});