diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de4978f318..acbb5ed24b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,8 +18,11 @@ jobs: - name: Compile assets run: npm run production + - name: Compile frontend assets + run: npm run frontend-prod + - name: Create zip - run: cd resources && tar -czvf dist.tar.gz dist + run: cd resources && tar -czvf dist.tar.gz dist dist-frontend - name: Get Changelog id: changelog diff --git a/.gitignore b/.gitignore index 6286b23fc6..758b4b407d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ node_modules .phpunit.result.cache tests/Fakes/Composer/Package/test-package/composer.json resources/dist +resources/dist-frontend composer.lock diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000000..f243a1f0ee --- /dev/null +++ b/babel.config.js @@ -0,0 +1,9 @@ +module.exports = { + "presets": [ + ["@babel/preset-env", { + "targets": { + "node": "current" + } + }] + ] +}; diff --git a/composer.json b/composer.json index 6b59456bb7..8577045468 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "extra": { "download-dist": { "url": "https://github.com/statamic/cms/releases/download/{$version}/dist.tar.gz", - "path": "resources/dist" + "path": "resources" }, "laravel": { "providers": [ diff --git a/frontend.mix.js b/frontend.mix.js new file mode 100644 index 0000000000..7979780d3e --- /dev/null +++ b/frontend.mix.js @@ -0,0 +1,9 @@ +const mix = require('laravel-mix'); +const src = 'resources'; +const dest = 'resources/dist-frontend'; + +mix.setPublicPath('./resources/dist-frontend'); + +mix.js(`${src}/js/frontend/helpers.js`, `${dest}/js`) + +mix.sourceMaps(); diff --git a/jest.config.js b/jest.config.js index 0b97ca2d84..7bc6acd31f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -129,7 +129,7 @@ module.exports = { // snapshotSerializers: [], // The test environment that will be used for testing - testEnvironment: "node", + testEnvironment: "jsdom", // Options that will be passed to the testEnvironment // testEnvironmentOptions: {}, @@ -167,9 +167,9 @@ module.exports = { // transform: null, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - // transformIgnorePatterns: [ - // "/node_modules/" - // ], + transformIgnorePatterns: [ + 'node_modules/(?!(underscore)/)' + ], // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // unmockedModulePathPatterns: undefined, diff --git a/package-lock.json b/package-lock.json index 82e3b94152..e474da6b02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "svgo": "^2.6.1", "sweetalert": "~1.0.1", "tiptap-extensions": "^1.28.6", - "underscore": "~1.9.2", + "underscore": "~1.13.2", "uniqid": "^5.2.0", "v-calendar": "^1.0.1", "v-tooltip": "^2.0.3", @@ -16984,9 +16984,9 @@ } }, "node_modules/underscore": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.2.tgz", - "integrity": "sha512-D39qtimx0c1fI3ya1Lnhk3E9nONswSKhnffBI0gME9C99fYOkNi04xs8K6pePLhvl1frbDemkaBQ5ikWllR2HQ==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz", + "integrity": "sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g==" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "1.0.4", @@ -32214,9 +32214,9 @@ } }, "underscore": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.2.tgz", - "integrity": "sha512-D39qtimx0c1fI3ya1Lnhk3E9nONswSKhnffBI0gME9C99fYOkNi04xs8K6pePLhvl1frbDemkaBQ5ikWllR2HQ==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz", + "integrity": "sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g==" }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", diff --git a/package.json b/package.json index cf55759b39..fb6b760c8a 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,11 @@ "prod": "npm run production", "production": "node node_modules/cross-env/dist/bin/cross-env.js NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", "svgo": "svgo -f ./resources/svg/ -r", - "test": "cross-env NODE_ENV=test jest", - "test-watch": "npm run test -- --watch --notify" + "test": "cross-env NODE_ENV=test jest --silent", + "test-watch": "npm run test -- --watch --notify", + "frontend-dev": "npm run development -- --env.mixfile=frontend.mix", + "frontend-prod": "npm run production -- --env.mixfile=frontend.mix", + "frontend-watch": "npm run watch -- --env.mixfile=frontend.mix" }, "dependencies": { "@popperjs/core": "^2.5.3", @@ -51,7 +54,7 @@ "svgo": "^2.6.1", "sweetalert": "~1.0.1", "tiptap-extensions": "^1.28.6", - "underscore": "~1.9.2", + "underscore": "~1.13.2", "uniqid": "^5.2.0", "v-calendar": "^1.0.1", "v-tooltip": "^2.0.3", diff --git a/resources/js/app.js b/resources/js/app.js index 0c5da68d2b..cd53de3c8d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -2,6 +2,11 @@ import Vue from 'vue'; import Toast from './mixins/Toast.js'; import Statamic from './components/Statamic.js'; import Alpine from 'alpinejs' +import * as Globals from './bootstrap/globals' + +let global_functions = Object.keys(Globals) +global_functions.forEach(fnName => { global[fnName] = Globals[fnName] }) +global.Cookies = require('cookies-js'); Vue.config.silent = false; Vue.config.devtools = true; @@ -14,7 +19,6 @@ window._ = require('underscore'); window.$ = window.jQuery = require('jquery'); window.rangy = require('rangy'); -require('./bootstrap/globals'); require('./bootstrap/polyfills'); require('./bootstrap/underscore-mixins'); require('./bootstrap/jquery-plugins'); diff --git a/resources/js/bootstrap/globals.js b/resources/js/bootstrap/globals.js index ec7dcd4663..b5379393d9 100644 --- a/resources/js/bootstrap/globals.js +++ b/resources/js/bootstrap/globals.js @@ -1,52 +1,50 @@ import { marked } from 'marked'; import { translate, translateChoice } from '../translations/translator'; -global.cp_url = function(url) { +export function cp_url(url) { url = Statamic.$config.get('cpUrl') + '/' + url; return tidy_url(url); }; -global.docs_url = function(url) { +export function docs_url(url) { return tidy_url('https://statamic.dev/' + url); }; -global.resource_url = function(url) { +export function resource_url(url) { url = Statamic.$config.get('resourceUrl') + '/' + url; return tidy_url(url); }; -global.tidy_url = function(url) { +export function tidy_url(url) { return url.replace(/([^:])(\/\/+)/g, '$1/') } -global.relative_url = function(url) { +export function relative_url(url) { return url.replace(/^(?:\/\/|[^/]+)*\//, '/'); } -global.file_icon = function(extension) { +export function file_icon(extension) { return resource_url('img/filetypes/'+ extension +'.png'); }; -global.dd = function(args) { +export function dd(args) { console.log(args); }; -global.data_get = function(obj, path, fallback=null) { +export function data_get(obj, path, fallback=null) { // Source: https://stackoverflow.com/a/22129960 var properties = Array.isArray(path) ? path : path.split('.'); var value = properties.reduce((prev, curr) => prev && prev[curr], obj); return value !== undefined ? value : fallback; }; -global.clone = function (value) { +export function clone(value) { if (value === undefined) return undefined; return JSON.parse(JSON.stringify(value)); } -global.Cookies = require('cookies-js'); - -global.tailwind_width_class = function (width) { +export function tailwind_width_class(width) { const widths = { 25: '1/4', 33: '1/3', @@ -59,18 +57,18 @@ global.tailwind_width_class = function (width) { return `w-${widths[width] || 'full'}`; } -global.markdown = function (value) { +export function markdown(value) { return marked(value); }; -global.__ = function (string, replacements) { +export function __(string, replacements) { return translate(string, replacements); } -global.__n = function (string, number, replacements) { +export function __n(string, number, replacements) { return translateChoice(string, number, replacements); } -global.utf8btoa = function (stringToEncode) { +export function utf8btoa(stringToEncode) { // first we convert it to utf-8 const utf8String = encodeURIComponent(stringToEncode) .replace(/%([0-9A-F]{2})/g, (_, code) => String.fromCharCode(`0x${code}`)); diff --git a/resources/js/components/assets/Editor/Editor.vue b/resources/js/components/assets/Editor/Editor.vue index c4f5223a40..7882f8bbaa 100644 --- a/resources/js/components/assets/Editor/Editor.vue +++ b/resources/js/components/assets/Editor/Editor.vue @@ -117,7 +117,7 @@ { + this.$axios.patch(url, this.visibleValues).then(response => { this.$emit('saved', response.data.asset); this.$toast.success(__('Saved')); this.saving = false; diff --git a/resources/js/components/data-list/HasHiddenFields.js b/resources/js/components/data-list/HasHiddenFields.js new file mode 100644 index 0000000000..131f2cf10b --- /dev/null +++ b/resources/js/components/data-list/HasHiddenFields.js @@ -0,0 +1,17 @@ +export default { + + computed: { + + hiddenFields() { + return this.$store.state.publish[this.publishContainer].hiddenFields; + }, + + visibleValues() { + return _.omit(this.values, (_, handle) => { + return this.hiddenFields[handle]; + }); + }, + + } + +} diff --git a/resources/js/components/entries/PublishForm.vue b/resources/js/components/entries/PublishForm.vue index 5227e6c9be..8840bd16c0 100644 --- a/resources/js/components/entries/PublishForm.vue +++ b/resources/js/components/entries/PublishForm.vue @@ -257,11 +257,13 @@ import PublishActions from './PublishActions'; import SaveButtonOptions from '../publish/SaveButtonOptions'; import RevisionHistory from '../revision-history/History'; import HasPreferences from '../data-list/HasPreferences'; +import HasHiddenFields from '../data-list/HasHiddenFields'; export default { mixins: [ HasPreferences, + HasHiddenFields, ], components: { @@ -464,7 +466,7 @@ export default { performSaveRequest() { // Once the hook has completed, we need to make the actual request. // We build the payload here because the before hook may have modified values. - const payload = { ...this.values, ...{ + const payload = { ...this.visibleValues, ...{ _blueprint: this.fieldset.handle, _localized: this.localizedFields, }}; diff --git a/resources/js/components/field-conditions/Converter.js b/resources/js/components/field-conditions/Converter.js index d2fe6593f1..6b18fe67a9 100644 --- a/resources/js/components/field-conditions/Converter.js +++ b/resources/js/components/field-conditions/Converter.js @@ -1,15 +1,22 @@ import { OPERATORS, ALIASES } from './Constants.js'; +import map from 'underscore/modules/map.js' +import each from 'underscore/modules/each.js' +import filter from 'underscore/modules/filter.js' +import chain from 'underscore/modules/chain.js' +import chainable from 'underscore/modules/mixin.js' + +chainable({ chain, filter, each }); export default class { fromBlueprint(conditions, prefix=null) { - return _.map(conditions, (condition, field) => this.splitRhs(field, condition, prefix)); + return map(conditions, (condition, field) => this.splitRhs(field, condition, prefix)); } toBlueprint(conditions) { let converted = {}; - _.each(conditions, condition => { + each(conditions, condition => { converted[condition.field] = this.combineRhs(condition); }); @@ -35,7 +42,7 @@ export default class { getOperatorFromRhs(condition) { let operator = '=='; - _.chain(this.getOperatorsAndAliases()) + chain(this.getOperatorsAndAliases()) .filter(value => new RegExp(`^${value} [^=]`).test(this.normalizeConditionString(condition))) .each(value => operator = value); @@ -51,7 +58,7 @@ export default class { getValueFromRhs(condition) { let rhs = this.normalizeConditionString(condition); - _.chain(this.getOperatorsAndAliases()) + chain(this.getOperatorsAndAliases()) .filter(value => new RegExp(`^${value} [^=]`).test(rhs)) .each(value => rhs = rhs.replace(new RegExp(`^${value}[ ]*`), '')); diff --git a/resources/js/components/field-conditions/Validator.js b/resources/js/components/field-conditions/Validator.js index c340e8d725..f227b89d47 100644 --- a/resources/js/components/field-conditions/Validator.js +++ b/resources/js/components/field-conditions/Validator.js @@ -1,5 +1,19 @@ import Converter from './Converter.js'; import { KEYS } from './Constants.js'; +import { data_get } from '../../bootstrap/globals.js' +import isString from 'underscore/modules/isString.js' +import isObject from 'underscore/modules/isObject.js' +import isEmpty from 'underscore/modules/isEmpty.js' +import intersection from 'underscore/modules/intersection.js' +import map from 'underscore/modules/map.js' +import each from 'underscore/modules/each.js' +import filter from 'underscore/modules/filter.js' +import reject from 'underscore/modules/reject.js' +import first from 'underscore/modules/first.js' +import chain from 'underscore/modules/chain.js' +import chainable from 'underscore/modules/mixin.js' + +chainable({ chain, map, each, filter, reject, first, isEmpty }); const NUMBER_SPECIFIC_COMPARISONS = [ '>', '>=', '<', '<=' @@ -9,7 +23,7 @@ export default class { constructor(field, values, store, storeName) { this.field = field; this.values = values; - this.rootValues = store.state.publish[storeName].values; + this.rootValues = store ? store.state.publish[storeName].values : false; this.store = store; this.storeName = storeName; this.passOnAny = false; @@ -22,7 +36,7 @@ export default class { if (conditions === undefined) { return true; - } else if (_.isString(conditions)) { + } else if (isString(conditions)) { return this.passesCustomCondition(this.prepareCondition(conditions)); } @@ -36,7 +50,7 @@ export default class { } getConditions() { - let key = _.chain(KEYS) + let key = chain(KEYS) .filter(key => this.field[key]) .first() .value(); @@ -57,7 +71,7 @@ export default class { } passesAllConditions(conditions) { - return _.chain(conditions) + return chain(conditions) .map(condition => this.prepareCondition(condition)) .reject(condition => this.passesCondition(condition)) .isEmpty() @@ -65,7 +79,7 @@ export default class { } passesAnyConditions(conditions) { - return ! _.chain(conditions) + return ! chain(conditions) .map(condition => this.prepareCondition(condition)) .filter(condition => this.passesCondition(condition)) .isEmpty() @@ -73,7 +87,7 @@ export default class { } prepareCondition(condition) { - if (_.isString(condition) || condition.operator === 'custom') { + if (isString(condition) || condition.operator === 'custom') { return this.prepareCustomCondition(condition); } @@ -115,17 +129,17 @@ export default class { } // When performing lhs.includes(), if lhs is not an object or array, cast to string. - if (operator === 'includes' && ! _.isObject(lhs)) { + if (operator === 'includes' && ! isObject(lhs)) { return lhs ? lhs.toString() : ''; } // When lhs is an empty string, cast to null. - if (_.isString(lhs) && _.isEmpty(lhs)) { + if (isString(lhs) && isEmpty(lhs)) { lhs = null; } // Prepare for eval() and return. - return _.isString(lhs) + return isString(lhs) ? JSON.stringify(lhs.trim()) : lhs; } @@ -152,7 +166,7 @@ export default class { } // Prepare for eval() and return. - return _.isString(rhs) + return isString(rhs) ? JSON.stringify(rhs.trim()) : rhs; } @@ -202,11 +216,11 @@ export default class { } if (condition.rhs === 'empty') { - condition.lhs = _.isEmpty(condition.lhs); + condition.lhs = isEmpty(condition.lhs); condition.rhs = true; } - if (_.isObject(condition.lhs)) { + if (isObject(condition.lhs)) { return false; } @@ -221,7 +235,7 @@ export default class { let values = condition.rhs.split(',').map(string => string.trim()); if (Array.isArray(condition.lhs)) { - return _.intersection(condition.lhs, values).length; + return intersection(condition.lhs, values).length; } return new RegExp(values.join('|')).test(condition.lhs); diff --git a/resources/js/components/field-conditions/ValidatorMixin.js b/resources/js/components/field-conditions/ValidatorMixin.js index e13baa676b..05c1f88b5d 100644 --- a/resources/js/components/field-conditions/ValidatorMixin.js +++ b/resources/js/components/field-conditions/ValidatorMixin.js @@ -9,9 +9,14 @@ export default { methods: { showField(field) { - let validator = new Validator(field, this.values, this.$store, this.storeName); + let passes = new Validator(field, this.values, this.$store, this.storeName).passesConditions(); - return validator.passesConditions(); + this.$store.commit(`publish/${this.storeName}/setHiddenField`, { + handle: field.handle, + hidden: ! passes, + }); + + return passes; } } } diff --git a/resources/js/components/field-validation/Rules.js b/resources/js/components/field-validation/Rules.js index 38daf9a180..abfb7aeeae 100644 --- a/resources/js/components/field-validation/Rules.js +++ b/resources/js/components/field-validation/Rules.js @@ -283,6 +283,10 @@ export default [ value: 'size:', example: 'size:value' }, + { + label: 'Sometimes', + value: 'sometimes', + }, { label: 'Starts With', value: 'starts_with:', diff --git a/resources/js/components/globals/PublishForm.vue b/resources/js/components/globals/PublishForm.vue index f7339ab56d..5b304a5f81 100644 --- a/resources/js/components/globals/PublishForm.vue +++ b/resources/js/components/globals/PublishForm.vue @@ -85,9 +85,14 @@ +{{ /form:contact }} +EOT + ); + + $expected = ""; + + $this->assertStringContainsString($expected, $output); + } + + /** @test */ + public function custom_driver_can_add_to_form_attributes() + { + $output = $this->tag('{{ form:contact js="custom_driver" }}{{ /form:contact }}'); + + $expected = '
'; + + $this->assertStringContainsString($expected, $output); + } + + /** @test */ + public function custom_driver_can_add_to_renderable_field_data() + { + $output = $this->tag(<<<'EOT' +{{ form:contact js="custom_driver" }} + {{ fields }} + + {{ /fields }} +{{ /form:contact }} +EOT + ); + + $expected = ""; + + $this->assertStringContainsString($expected, $output); + } + + /** @test */ + public function custom_driver_can_add_to_renderable_field_attributes() + { + $output = $this->normalizeHtml($this->tag(<<<'EOT' +{{ form:contact js="custom_driver" }} + {{ fields }} + {{ field }} + {{ /fields }} +{{ /form:contact }} +EOT + )); + + $expected = ''; + $this->assertStringContainsString($expected, $output); + } + + /** @test */ + public function it_validates_required_show_field_output_in_renderable_field_data() + { + CustomDriverWithoutShowField::register(); + + $this->expectExceptionMessage('JS driver requires [show_field] to be defined in [addToRenderableFieldData()] output!'); + + $this->tag('{{ form:contact js="custom_driver_without_show_field" }}{{ js_driver }}{{ /form:contact }}'); + } + + /** @test */ + public function custom_driver_get_show_field_js_in_dynamic_fields_array() + { + $output = $this->tag(<<<'EOT' +{{ form:contact js="custom_driver" }} + {{ fields }} + + {{ /fields }} +{{ /form:contact }} +EOT + ); + + $expected = ""; + + $this->assertStringContainsString($expected, $output); + } + + /** @test */ + public function custom_driver_get_show_field_js_at_top_level_for_when_hardcoding_field_html() + { + $output = $this->tag(<<<'EOT' +{{ form:contact js="custom_driver" }} + +{{ /form:contact }} +EOT + ); + + $expected = ""; + + $this->assertStringContainsString($expected, $output); + } + + /** @test */ + public function custom_driver_can_render_component_around_form() + { + $output = $this->tag('{{ form:contact js="custom_driver" }}{{ /form:contact }}'); + + $this->assertStringContainsString('assertStringContainsString('
', $output); + } + + /** @test */ + public function it_validates_render_method_returns_html_var() + { + $this->expectExceptionMessage('JS driver requires [$html] to be returned in [render()] output!'); + + CustomDriverWithBadRenderMethod::register(); + + $this->tag('{{ form:contact js="custom_driver_with_bad_render_method" }}{{ /form:contact }}'); + } + + /** @test */ + public function custom_driver_can_get_initial_form_data() + { + $driver = new CustomDriver(Form::find('contact')); + + $initialData = $driver->runProtectedGetInitialFormDataHelper(); + + $expected = [ + 'name' => null, + 'email' => null, + 'message' => null, + ]; + + $this->assertEquals($expected, $initialData); + } + + /** @test */ + public function custom_driver_getting_initial_data_respects_old_data() + { + $this + ->post('/!/forms/contact', [ + 'name' => 'San Holo', + ]) + ->assertSessionHasErrors(['email'], null, 'form.contact') + ->assertLocation('/'); + + $driver = new CustomDriver(Form::find('contact')); + + $initialData = $driver->runProtectedGetInitialFormDataHelper(); + + $expected = [ + 'name' => 'San Holo', + 'email' => null, + 'message' => null, + ]; + + $this->assertEquals($expected, $initialData); + } +} + +class CustomDriver extends AbstractJsDriver +{ + public function addToFormData($data) + { + return [ + 'custom_form_js' => "alert('the authorities')", + ]; + } + + public function addToFormAttributes() + { + return [ + 'z-data' => $this->jsonEncodeForHtmlAttribute(['lol' => 'catz', 'handle' => $this->form->handle()]), + 'z-rad' => 'absolutely', + ]; + } + + public function addToRenderableFieldData($field, $data) + { + return [ + 'show_field' => "alert('the stormtroopers')", + 'custom_field_js' => "alert('the sith')", + ]; + } + + public function addToRenderableFieldAttributes($field) + { + return [ + 'z-unless' => "Statamic.\$conditions.showField('{$field->handle()}', __zData)", + 'z-gnarley' => true, + ]; + } + + public function render($html) + { + return "{$html}"; + } + + public function runProtectedGetInitialFormDataHelper() + { + return $this->getInitialFormData(); + } +} + +class CustomDriverWithoutShowField extends AbstractJsDriver +{ + public function addToRenderableFieldData($data, $field) + { + return [ + // 'show_field' => 'This is required and should be validated at runtime', + ]; + } +} + +class CustomDriverWithBadRenderMethod extends AbstractJsDriver +{ + public function render($html) + { + return 'oops, forgot to return $html var!'; + } +} diff --git a/tests/Tags/Form/FormCreateTest.php b/tests/Tags/Form/FormCreateTest.php index a878fde736..d3b0725362 100644 --- a/tests/Tags/Form/FormCreateTest.php +++ b/tests/Tags/Form/FormCreateTest.php @@ -149,7 +149,7 @@ public function it_dynamically_renders_checkboxes_field() '
', '', '
', - '', + '', ], [ 'handle' => 'favourite_animals', 'field' => [ @@ -157,7 +157,7 @@ public function it_dynamically_renders_checkboxes_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ]); @@ -167,7 +167,7 @@ public function it_dynamically_renders_checkboxes_field() '
', '', '
', - '', + '', ], [ 'handle' => 'favourite_animals', 'field' => [ @@ -175,7 +175,7 @@ public function it_dynamically_renders_checkboxes_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ], [ @@ -189,7 +189,7 @@ public function it_dynamically_renders_inline_checkboxes_field() $this->assertFieldRendersHtml([ '', '', - '', + '', ], [ 'handle' => 'favourite_animals', 'field' => [ @@ -198,7 +198,7 @@ public function it_dynamically_renders_inline_checkboxes_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ]); @@ -206,7 +206,7 @@ public function it_dynamically_renders_inline_checkboxes_field() $this->assertFieldRendersHtml([ '', '', - '', + '', ], [ 'handle' => 'favourite_animals', 'field' => [ @@ -215,7 +215,7 @@ public function it_dynamically_renders_inline_checkboxes_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ], [ @@ -231,7 +231,7 @@ public function it_dynamically_renders_radio_field() '
', '', '
', - '', + '', ], [ 'handle' => 'favourite_animal', 'field' => [ @@ -239,7 +239,7 @@ public function it_dynamically_renders_radio_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ]); @@ -249,7 +249,7 @@ public function it_dynamically_renders_radio_field() '
', '', '
', - '', + '', ], [ 'handle' => 'favourite_animal', 'field' => [ @@ -257,7 +257,7 @@ public function it_dynamically_renders_radio_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ], [ @@ -271,7 +271,7 @@ public function it_dynamically_renders_inline_radio_field() $this->assertFieldRendersHtml([ '', '', - '', + '', ], [ 'handle' => 'favourite_animal', 'field' => [ @@ -280,7 +280,7 @@ public function it_dynamically_renders_inline_radio_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ]); @@ -288,7 +288,7 @@ public function it_dynamically_renders_inline_radio_field() $this->assertFieldRendersHtml([ '', '', - '', + '', ], [ 'handle' => 'favourite_animal', 'field' => [ @@ -297,7 +297,7 @@ public function it_dynamically_renders_inline_radio_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ], [ @@ -313,7 +313,7 @@ public function it_dynamically_renders_select_field() '', '', '', - '', + '', '', ], [ 'handle' => 'favourite_animal', @@ -322,7 +322,7 @@ public function it_dynamically_renders_select_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ]); @@ -332,7 +332,7 @@ public function it_dynamically_renders_select_field() '', '', '', - '', + '', '', ], [ 'handle' => 'favourite_animal', @@ -341,7 +341,7 @@ public function it_dynamically_renders_select_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ], [ @@ -356,7 +356,7 @@ public function it_dynamically_renders_multiple_select_field() '', ], [ 'handle' => 'favourite_animals', @@ -366,7 +366,7 @@ public function it_dynamically_renders_multiple_select_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ]); @@ -375,7 +375,7 @@ public function it_dynamically_renders_multiple_select_field() '', ], [ 'handle' => 'favourite_animals', @@ -385,7 +385,7 @@ public function it_dynamically_renders_multiple_select_field() 'options' => [ 'cat' => 'Cat', 'armadillo' => 'Armadillo', - 'rat' => 'Rat', + 'rat' => null, // label should fall back to value ], ], ], [ @@ -452,7 +452,11 @@ public function it_wont_submit_form_and_renders_errors() $this->assertEmpty(Form::find('contact')->submissions()); $this - ->post('/!/forms/contact') + ->post('/!/forms/contact', [ + 'name' => '', + 'email' => '', + 'message' => '', + ]) ->assertSessionHasErrors(['email', 'message'], null, 'form.contact') ->assertLocation('/'); @@ -597,6 +601,9 @@ public function it_wont_submit_form_and_follow_custom_redirect_with_errors() $this ->post('/!/forms/contact', [ '_error_redirect' => '/submission-error', + 'name' => '', + 'email' => '', + 'message' => '', ]) ->assertSessionHasErrors(['email', 'message'], null, 'form.contact') ->assertLocation('/submission-error'); @@ -651,7 +658,11 @@ public function it_can_render_an_inline_error_when_multiple_rules_fail() $this->assertEmpty(Form::find('contact')->submissions()); $this - ->post('/!/forms/contact', ['name' => '$']) + ->post('/!/forms/contact', [ + 'name' => '$', + 'email' => '', + 'message' => '', + ]) ->assertSessionHasErrors(['name', 'email', 'message'], null, 'form.contact') ->assertLocation('/'); diff --git a/tests/Tags/Form/FormErrorsTest.php b/tests/Tags/Form/FormErrorsTest.php index 23b9341264..c18adfde4a 100644 --- a/tests/Tags/Form/FormErrorsTest.php +++ b/tests/Tags/Form/FormErrorsTest.php @@ -8,7 +8,11 @@ class FormErrorsTest extends FormTestCase public function it_renders_errors() { $this - ->post('/!/forms/contact') + ->post('/!/forms/contact', [ + 'name' => null, + 'email' => null, + 'message' => null, + ]) ->assertSessionHasErrors(['email', 'message'], null, 'form.contact') ->assertLocation('/'); @@ -28,4 +32,18 @@ public function it_renders_errors() $this->assertEquals($expected, $errors[1]); } + + /** @test */ + public function it_allows_use_of_sometimes_rule_for_conditionally_hidden_fields() + { + $this + ->post('/!/forms/contact', [ + 'name' => null, + 'email' => null, + // 'message' => 'Has both the `sometimes` and `required` rule, so it should only validate if in then request.', + ]) + ->assertSessionHasErrors(['email'], null, 'form.contact') + ->assertSessionDoesntHaveErrors(['message'], null, 'form.contact') + ->assertLocation('/'); + } } diff --git a/tests/Tags/Form/FormTestCase.php b/tests/Tags/Form/FormTestCase.php index dabb9bf58c..1b98540c81 100644 --- a/tests/Tags/Form/FormTestCase.php +++ b/tests/Tags/Form/FormTestCase.php @@ -6,6 +6,7 @@ use Statamic\Facades\Form; use Statamic\Facades\Parse; use Statamic\Support\Arr; +use Statamic\Support\Html; use Tests\NormalizesHtml; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -14,6 +15,34 @@ abstract class FormTestCase extends TestCase { use PreventSavingStacheItemsToDisk, NormalizesHtml; + protected $defaultFields = [ + [ + 'handle' => 'name', + 'field' => [ + 'type' => 'text', + 'display' => 'Full Name', + 'validate' => 'min:3|alpha_num', + ], + ], + [ + 'handle' => 'email', + 'field' => [ + 'type' => 'text', + 'input_type' => 'email', + 'display' => 'Email Address', + 'validate' => 'required|email', + ], + ], + [ + 'handle' => 'message', + 'field' => [ + 'type' => 'textarea', + 'display' => 'Message', + 'validate' => 'sometimes|required', + ], + ], + ]; + public function setUp(): void { parent::setUp(); @@ -43,36 +72,8 @@ protected function tag($tag) protected function createContactForm($fields = null) { - $defaultFields = [ - [ - 'handle' => 'name', - 'field' => [ - 'type' => 'text', - 'display' => 'Full Name', - 'validate' => 'min:3|alpha_num', - ], - ], - [ - 'handle' => 'email', - 'field' => [ - 'type' => 'text', - 'input_type' => 'email', - 'display' => 'Email Address', - 'validate' => 'required|email', - ], - ], - [ - 'handle' => 'message', - 'field' => [ - 'type' => 'textarea', - 'display' => 'Message', - 'validate' => 'required', - ], - ], - ]; - $blueprint = Blueprint::make()->setContents([ - 'fields' => $fields ?? $defaultFields, + 'fields' => $fields ?? $this->defaultFields, ]); $handle = $fields ? $this->customFieldBlueprintHandle : 'contact'; @@ -86,7 +87,7 @@ protected function createContactForm($fields = null) Form::makePartial(); } - protected function assertFieldRendersHtml($expectedHtmlParts, $fieldConfig, $oldData = []) + protected function assertFieldRendersHtml($expectedHtmlParts, $fieldConfig, $oldData = [], $extraParams = []) { $randomString = str_shuffle('nobodymesseswiththehoff'); @@ -104,8 +105,10 @@ protected function assertFieldRendersHtml($expectedHtmlParts, $fieldConfig, $old ->assertLocation('/'); } + $extraParams = $extraParams ? Html::attributes($extraParams) : ''; + $output = $this->normalizeHtml( - $this->tag("{{ form:{$handle} }}{{ fields }}{{ field}}{{ /fields }}{{ /form:{$handle} }}", $oldData) + $this->tag("{{ form:{$handle} {$extraParams}}}{{ fields }}{{ field}}{{ /fields }}{{ /form:{$handle} }}", $oldData) ); $expected = collect(Arr::wrap($expectedHtmlParts))->implode('');