Skip to content

[6.x] Misc dynamic form builder preparations #10976

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 83 commits into from
Mar 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
957b5e2
wip
jesseleite Oct 18, 2024
e09ec8e
Deciding against doc here. We don’t do it for any of the other fieldt…
jesseleite Oct 18, 2024
5e65b37
This prefix is no longer used.
jesseleite Oct 18, 2024
083fa3a
Clean up.
jesseleite Oct 18, 2024
37daeff
Merge branch '5.x' of https://github.com/statamic/cms into frontend-f…
jesseleite Jan 17, 2025
aeee81e
Merge branch '5.x' of https://github.com/statamic/cms into frontend-f…
jesseleite Feb 6, 2025
a2e8b19
Refactor `handle` to `name`, since we need `array[syntax]` for `name`.
jesseleite Feb 6, 2025
e3bef62
wip
jesseleite Feb 6, 2025
c916f96
wip
jesseleite Feb 6, 2025
cf692c3
Make it work with `{{ $field }}` braces.
jesseleite Feb 6, 2025
10d444c
Types.
jesseleite Feb 6, 2025
82f98a5
Pass slot instance down, instead of re-instantiating using template i…
jesseleite Feb 6, 2025
abc6d9a
Fix styling
jesseleite Feb 6, 2025
24e35ce
Clean up.
jesseleite Feb 6, 2025
74c9e07
Merge branch 'frontend-form-group' of https://github.com/statamic/cms…
jesseleite Feb 27, 2025
51dfad7
Merge branch '5.x' of https://github.com/statamic/cms into frontend-f…
jesseleite Feb 27, 2025
3102477
Test feature parity between legacy `fields` array syntax and new `for…
jesseleite Feb 28, 2025
5c65b20
Always out nulls in checkbox `process()` for recursion.
jesseleite Feb 28, 2025
22ab18d
Add recursive `group` field test coverage.
jesseleite Feb 28, 2025
2ac8d5a
Merge branch '5.x' of https://github.com/statamic/cms into frontend-f…
jesseleite Feb 28, 2025
b6a7332
Fix `frontend-dev` helpers.js watcher.
jesseleite Mar 4, 2025
87b7a71
Pass current field path into JS `showField()`.
jesseleite Mar 6, 2025
c79f9e3
Make sure `Validator.js` gets the current field path.
jesseleite Mar 6, 2025
907fbaf
Merge branch 'master' of https://github.com/statamic/cms into fronten…
jesseleite Mar 6, 2025
4a72bfe
I'm bad at vim.
jesseleite Mar 6, 2025
f94823d
Fix watcher again.
jesseleite Mar 6, 2025
f503e93
Rename var for clarity.
jesseleite Mar 6, 2025
5805437
This is already supported as third argument.
jesseleite Mar 6, 2025
2cd0205
Quote third param.
jesseleite Mar 6, 2025
49b44e9
Extract `showField()` test helper.
jesseleite Mar 7, 2025
aa393cb
Add frontend conditions coverage for group field and `$parent` and `$…
jesseleite Mar 7, 2025
d814af8
Add frontend logic to pass new tests.
jesseleite Mar 7, 2025
7d648bd
Unlike in CP, data passed into frontend forms is always relative to r…
jesseleite Mar 7, 2025
3d5983e
Checkbox validation was broken because we needed same logic on `prePr…
jesseleite Mar 7, 2025
eb196b7
Don’t `x-model` at top level of group field.
jesseleite Mar 7, 2025
89e8865
Merge branch 'master' of https://github.com/statamic/cms into fronten…
jesseleite Mar 10, 2025
42aa28f
Like master.
jesseleite Mar 10, 2025
c8d1d02
Make static and improve test.
jesseleite Mar 10, 2025
a897913
This indeed works.
jesseleite Mar 10, 2025
ab4aea4
Forgot to update these test traits with the others.
jesseleite Mar 10, 2025
6e15a6b
Undo overzealous commit that got grouped into merge conflict commit.
jesseleite Mar 10, 2025
9ee8cf3
Fix recursive `form:fields` test.
jesseleite Mar 10, 2025
ccfda34
Add group field test data to alpine test.
jesseleite Mar 11, 2025
73e7978
Flesh out test coverage for top-level `show_field` array.
jesseleite Mar 11, 2025
f135a00
Fix `show_field` recursion and pass failing test.
jesseleite Mar 11, 2025
0b40b3a
Misc cleanup.
jesseleite Mar 11, 2025
be4ca24
Flesh out `form:fields` coverage in alpine test.
jesseleite Mar 11, 2025
b37d02c
Todo. (Solve with ArrayAccess?)
jesseleite Mar 11, 2025
f6b5b5f
Remove bad `ArrayAccess` idea.
jesseleite Mar 11, 2025
e6386f2
Flesh out `form:show_field` (and backward compatible `show_field` arr…
jesseleite Mar 11, 2025
6bb825c
Improve exception message.
jesseleite Mar 11, 2025
f4b7a63
Tag wasn’t really blade friendly because of `Statamic::tag()` missing…
jesseleite Mar 11, 2025
1ae5296
Merge branch 'master' of https://github.com/statamic/cms into fronten…
jesseleite Mar 12, 2025
7ada93f
Make these pass again too.
jesseleite Mar 12, 2025
3582d29
Improve scoped form test coverage.
jesseleite Mar 12, 2025
a7d1f3f
Run prettier:format.
jesseleite Mar 12, 2025
4e181d2
Not using this addition anymore.
jesseleite Mar 12, 2025
31665ce
Test that it renders group field, without x-model on group itself.
jesseleite Mar 12, 2025
7a827a7
This needs to use new recursive tag now.
jesseleite Mar 12, 2025
d389a1d
Don’t use dots in html ids.
jesseleite Mar 12, 2025
8e9fb9e
Fix issue with child form handles.
jesseleite Mar 12, 2025
4b7eef9
Realizing this default styling is not necessary now that they control…
jesseleite Mar 12, 2025
b315a47
Update this test now that we don’t force default styles.
jesseleite Mar 12, 2025
44e3ddd
Add recursive test recursive test recursive test.
jesseleite Mar 12, 2025
35eb21d
Field name is already in form field ids now.
jesseleite Mar 12, 2025
d046c0f
Make field ids prettier slugs.
jesseleite Mar 12, 2025
b20d2bf
Pass tests again.
jesseleite Mar 12, 2025
a8f2084
Never used this.
jesseleite Mar 13, 2025
9d5ff2e
Alpine driver shouldn’t handle fieldtype-specific logic directly.
jesseleite Mar 13, 2025
592a170
Remove `group` fieldtype specific logic from `RendersForms` trait.
jesseleite Mar 13, 2025
1f0d685
Merge branch 'master' of https://github.com/statamic/cms into fronten…
jesseleite Mar 13, 2025
f8024ba
Add test coverage for bug found when submitting partial/visible neste…
jesseleite Mar 13, 2025
a02932b
Recursively add old values to fields for initial x-data.
jesseleite Mar 13, 2025
4c2d269
Whoops, this isn't used anymore.
jesseleite Mar 13, 2025
8dae3f9
Merge branch 'master' of https://github.com/statamic/cms into fronten…
jesseleite Mar 14, 2025
eab54b8
Whoops.
jesseleite Mar 14, 2025
1972cd8
Not todo.
jesseleite Mar 26, 2025
6b14d74
Merge branch 'master' of https://github.com/statamic/cms into fronten…
jesseleite Mar 26, 2025
907ac1d
Support `scope` param on `form:fields` tag.
jesseleite Mar 26, 2025
52c52ff
Add failing test.
jesseleite Mar 26, 2025
ede8ce3
Fix missing `$params` error.
jesseleite Mar 26, 2025
30f2c10
Pass `scope` into `form:fields` slot class, and handle _after_ view r…
jesseleite Mar 26, 2025
a3e77f6
Don’t need this (`$isBlade` is recursively passed to slot instance wh…
jesseleite Mar 26, 2025
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"svgo": "svgo -f ./resources/svg/ -r",
"test": "vitest run",
"test-watch": "npm run test -- --watch --notify",
"frontend-dev": "vite -c vite-frontend.config.js",
"frontend-dev": "vite build -c vite-frontend.config.js --watch",
"frontend-build": "vite build -c vite-frontend.config.js",
"prettier:format": "prettier --write \"**/*.{js,ts,jsx,tsx,cjs,cts,mjs,mts,vue,blade.php,antlers.html,css}\"",
"prettier:check": "prettier --check \"**/*.{js,ts,jsx,tsx,cjs,cts,mjs,mts,vue,blade.php,antlers.html,css}\"",
Expand Down
32 changes: 27 additions & 5 deletions resources/js/components/field-conditions/Validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,31 @@ const isEmpty = (value) => {
const isString = (str) => str != null && typeof str.valueOf() === 'string';

export default class {
constructor(field, values, dottedFieldPath, store) {
constructor(field, values, currentFieldPath, store) {
this.field = field;
this.values = values;
this.dottedFieldPath = dottedFieldPath;
this.currentFieldPath = currentFieldPath;
this.store = store;
this.rootValues = store ? store.values : false;
this.passOnAny = false;
this.showOnPass = true;
this.converter = new Converter();
}

usingRootValues() {
if (!this.currentFieldPath) {
throw new Error('[currentFieldPath] constructor param required for `usingRootValues()`');
}

this.rootValues = this.values;

if (this.currentFieldPath.includes('.')) {
return this.scopeValuesToParent();
}

return this;
}

passesConditions(specificConditions) {
let conditions = specificConditions || this.getConditions();

Expand Down Expand Up @@ -185,7 +199,7 @@ export default class {

getFieldValue(field) {
if (field.startsWith('$parent.')) {
field = new ParentResolver(this.dottedFieldPath).resolve(field);
field = new ParentResolver(this.currentFieldPath).resolve(field);
}

if (field.startsWith('$root.') || field.startsWith('root.')) {
Expand Down Expand Up @@ -249,7 +263,7 @@ export default class {
values: this.values,
root: this.rootValues,
store: this.store,
fieldPath: this.dottedFieldPath,
fieldPath: this.currentFieldPath,
});

return this.showOnPass ? passes : !passes;
Expand All @@ -273,7 +287,7 @@ export default class {

relativeLhsToAbsoluteFieldPath(lhs, dottedPrefix) {
if (lhs.startsWith('$parent.')) {
lhs = new ParentResolver(this.dottedFieldPath).resolve(lhs);
lhs = new ParentResolver(this.currentFieldPath).resolve(lhs);
}

if (lhs.startsWith('$root.') || lhs.startsWith('root.')) {
Expand All @@ -282,4 +296,12 @@ export default class {

return dottedPrefix ? dottedPrefix + '.' + lhs : lhs;
}

scopeValuesToParent() {
let scope = this.currentFieldPath.replace(new RegExp('\.[^\.]+$'), '');

this.values = data_get(this.rootValues, scope);

return this;
}
}
4 changes: 2 additions & 2 deletions resources/js/frontend/components/FieldConditions.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Validator from '../../components/field-conditions/Validator.js';

export default class {
showField(field, data) {
return new Validator(field, data).passesConditions();
showField(conditions, data, currentFieldPath) {
return new Validator(conditions, data, currentFieldPath).usingRootValues().passesConditions();
}
}
57 changes: 48 additions & 9 deletions resources/js/tests/Frontend/FieldConditionsTest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,67 @@ import { test, expect } from 'vitest';
import '../../frontend/helpers.js';

let formData = {
target: 'test',
first_name: 'Bilbo',
last_name: 'Baggins',
hobby: '',
bio: null,
group_field: {
dwelling: 'Bag End',
village: 'Hobbiton',
nested_group_field: {
birthday: 'Sept 22',
age: '111',
},
},
};

let showField = function (conditions, dottedFieldPath = null) {
return Statamic.$conditions.showField(conditions, formData, dottedFieldPath || 'target');
};

test('it shows field by default', () => {
expect(Statamic.$conditions.showField([], formData)).toBe(true);
expect(Statamic.$conditions.showField({}, formData)).toBe(true);
expect(showField([])).toBe(true);
expect(showField({})).toBe(true);
});

test('it can show field based on empty checks', () => {
expect(Statamic.$conditions.showField({ if: { hobby: 'empty' } }, formData)).toBe(true);
expect(Statamic.$conditions.showField({ if: { bio: 'empty' } }, formData)).toBe(true);
expect(Statamic.$conditions.showField({ if: { first_name: 'empty' } }, formData)).toBe(false);
expect(Statamic.$conditions.showField({ if: { first_name: 'not empty' } }, formData)).toBe(true);
expect(showField({ if: { hobby: 'empty' } })).toBe(true);
expect(showField({ if: { bio: 'empty' } })).toBe(true);
expect(showField({ if: { first_name: 'empty' } })).toBe(false);
expect(showField({ if: { first_name: 'not empty' } })).toBe(true);
});

test('it can show field if multiple conditions are met', () => {
expect(Statamic.$conditions.showField({ if: { first_name: 'Bilbo', last_name: 'Baggins' } }, formData)).toBe(true);
expect(Statamic.$conditions.showField({ if: { first_name: 'Frodo', last_name: 'Baggins' } }, formData)).toBe(false);
expect(Statamic.$conditions.showField({ if_any: { first_name: 'Frodo', last_name: 'Baggins' } }, formData)).toBe(
expect(showField({ if: { first_name: 'Bilbo', last_name: 'Baggins' } })).toBe(true);
expect(showField({ if: { first_name: 'Frodo', last_name: 'Baggins' } })).toBe(false);
expect(showField({ if_any: { first_name: 'Frodo', last_name: 'Baggins' } })).toBe(true);
});

test('it can show field nested in group based on sibling field value', () => {
expect(showField({ if: { village: 'Hobbiton' } }, 'group_field.dwelling')).toBe(true);
expect(showField({ if: { village: 'Mordor' } }, 'group_field.dwelling')).toBe(false);
});

test('it can show deeply field nested in group based on sibling field value', () => {
expect(showField({ if: { age: 111 } }, 'group_field.nested_group_field.birthday')).toBe(true);
expect(showField({ if: { age: 112 } }, 'group_field.nested_group_field.birthday')).toBe(false);
});

test('it can show deeply field nested in group based on parent field value', () => {
expect(showField({ if: { '$parent.village': 'Hobbiton' } }, 'group_field.nested_group_field.birthday')).toBe(true);
expect(showField({ if: { '$parent.village': 'Mordor' } }, 'group_field.nested_group_field.birthday')).toBe(false);
expect(
showField({ if: { '$parent.$parent.first_name': 'not empty' } }, 'group_field.nested_group_field.birthday'),
).toBe(true);
expect(showField({ if: { '$parent.$parent.hobby': 'not empty' } }, 'group_field.nested_group_field.birthday')).toBe(
false,
);
});

test('it can show deeply field nested in group based on root field value', () => {
expect(showField({ if: { '$root.first_name': 'not empty' } }, 'group_field.nested_group_field.birthday')).toBe(
true,
);
expect(showField({ if: { '$root.hobby': 'not empty' } }, 'group_field.nested_group_field.birthday')).toBe(false);
});
2 changes: 1 addition & 1 deletion resources/views/extend/forms/fields/assets.antlers.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<input
id="{{ id }}"
type="file"
name="{{ handle }}{{ if max_files !== 1 }}[]{{ /if }}"
name="{{ name }}{{ if max_files !== 1 }}[]{{ /if }}"
{{ if js_driver }}{{ js_attributes }}{{ /if }}
{{ if max_files !== 1 }}multiple{{ /if }}
{{ if validate|contains:required }}required{{ /if }}
Expand Down
4 changes: 2 additions & 2 deletions resources/views/extend/forms/fields/checkboxes.antlers.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<input type="hidden" name="{{ handle }}[]">
<input type="hidden" name="{{ name }}[]">
{{ foreach:options as="option|label" }}
<label>
<input
id="{{ id }}-{{ option | slugify }}-option"
type="checkbox"
name="{{ handle }}[]"
name="{{ name }}[]"
value="{{ option }}"
{{ if js_driver }}{{ js_attributes }}{{ /if }}
{{ if value|in_array:option }}checked{{ /if }}
Expand Down
2 changes: 1 addition & 1 deletion resources/views/extend/forms/fields/default.antlers.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<input
id="{{ id }}"
type="{{ input_type ?? 'text' }}"
name="{{ handle }}"
name="{{ name }}"
value="{{ value }}"
{{ if placeholder }}placeholder="{{ placeholder }}"{{ /if }}
{{ if character_limit }}maxlength="{{ character_limit }}"{{ /if }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<select
id="{{ id }}"
name="{{ handle }}{{ multiple ?= "[]" }}"
name="{{ name }}{{ multiple ?= "[]" }}"
{{ if js_driver }}{{ js_attributes }}{{ /if }}
{{ if multiple }}multiple{{ /if }}
{{ if validate|contains:required }}required{{ /if }}
Expand Down
2 changes: 1 addition & 1 deletion resources/views/extend/forms/fields/files.antlers.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<input
id="{{ id }}"
type="file"
name="{{ handle }}{{ if max_files !== 1 }}[]{{ /if }}"
name="{{ name }}{{ if max_files !== 1 }}[]{{ /if }}"
{{ if js_driver }}{{ js_attributes }}{{ /if }}
{{ if max_files !== 1 }}multiple{{ /if }}
{{ if validate|contains:required }}required{{ /if }}
Expand Down
5 changes: 5 additions & 0 deletions resources/views/extend/forms/fields/group.antlers.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div {{ if js_driver }}{{ js_attributes }}{{ /if }}>
{{ fields scope="__field" }}
{{ slot:addContext(__field) }}
{{ /fields }}
</div>
2 changes: 1 addition & 1 deletion resources/views/extend/forms/fields/integer.antlers.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<input
type="number"
name="{{ handle }}"
name="{{ name }}"
value="{{ value }}"
{{ if placeholder }}placeholder="{{ placeholder }}"{{ /if }}
{{ if character_limit }}maxlength="{{ character_limit }}"{{ /if }}
Expand Down
2 changes: 1 addition & 1 deletion resources/views/extend/forms/fields/radio.antlers.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<input
id="{{ id }}-{{ option | slugify }}-option"
type="radio"
name="{{ handle }}"
name="{{ name }}"
value="{{ option }}"
{{ if js_driver }}{{ js_attributes }}{{ /if }}
{{ if value == option }}checked{{ /if }}
Expand Down
2 changes: 1 addition & 1 deletion resources/views/extend/forms/fields/select.antlers.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<select
id="{{ id }}"
name="{{ handle }}{{ multiple ?= "[]" }}"
name="{{ name }}{{ multiple ?= "[]" }}"
{{ if js_driver }}{{ js_attributes }}{{ /if }}
{{ if multiple }}multiple{{ /if }}
{{ if validate|contains:required }}required{{ /if }}
Expand Down
2 changes: 1 addition & 1 deletion resources/views/extend/forms/fields/text.antlers.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<input
id="{{ id }}"
type="{{ input_type ?? 'text' }}"
name="{{ handle }}"
name="{{ name }}"
value="{{ value }}"
{{ if placeholder }}placeholder="{{ placeholder }}"{{ /if }}
{{ if character_limit }}maxlength="{{ character_limit }}"{{ /if }}
Expand Down
2 changes: 1 addition & 1 deletion resources/views/extend/forms/fields/textarea.antlers.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<textarea
id="{{ id }}"
name="{{ handle }}"
name="{{ name }}"
rows="5"
{{ if placeholder }}placeholder="{{ placeholder }}"{{ /if }}
{{ if character_limit }}maxlength="{{ character_limit }}"{{ /if }}
Expand Down
4 changes: 2 additions & 2 deletions resources/views/extend/forms/fields/toggle.antlers.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<label>
<input type="hidden" name="{{ handle }}" value="0">
<input type="hidden" name="{{ name }}" value="0">
<input
id="{{ id }}"
type="checkbox"
name="{{ handle }}"
name="{{ name }}"
value="1"
{{ if js_driver }}{{ js_attributes }}{{ /if }}
{{ if value && value !== '0' }}checked{{ /if }}
Expand Down
10 changes: 10 additions & 0 deletions src/Fields/Fieldtype.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ public function preProcessValidatable($value)
return $value;
}

public function preProcessTagRenderable($data, $recursiveCallback)
{
return $data;
}

public function defaultValue()
{
return $this->defaultValue;
Expand Down Expand Up @@ -390,4 +395,9 @@ public function extraRenderableFieldData(): array
{
return [];
}

public function hasJsDriverDataBinding(): bool
{
return true;
}
}
17 changes: 16 additions & 1 deletion src/Fieldtypes/Checkboxes.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

class Checkboxes extends Fieldtype
{
use HasSelectOptions;
use HasSelectOptions {
process as traitProcess;
}

protected $categories = ['controls'];
protected $selectableInForms = true;
Expand Down Expand Up @@ -51,4 +53,17 @@ protected function multiple()
{
return true;
}

public function preProcessValidatable($value)
{
return collect($value)->filter()->values()->all();
}

public function process($data)
{
return collect($this->traitProcess($data))
->reject(fn ($value) => $value === null)
->values()
->all();
}
}
19 changes: 19 additions & 0 deletions src/Fieldtypes/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Group extends Fieldtype
{
protected $categories = ['structured'];
protected $defaultable = false;
protected $selectableInForms = true;

protected function configFieldItems(): array
{
Expand Down Expand Up @@ -132,6 +133,19 @@ public function preProcessValidatable($value)
);
}

public function preProcessTagRenderable($data, $recursiveCallback)
{
$field = $this->field();

$data['fields'] = collect($this->fields()->all())
->map(fn ($child) => $child->setForm($field->form())->setHandle($field->handle().'.'.$child->handle()))
->map(fn ($child) => $recursiveCallback($child))
->values()
->all();

return $data;
}

public function toGqlType()
{
return GraphQL::type($this->gqlItemTypeName());
Expand All @@ -152,4 +166,9 @@ private function gqlItemTypeName()
return Str::studly($part);
})->join('_');
}

public function hasJsDriverDataBinding(): bool
{
return false;
}
}
4 changes: 0 additions & 4 deletions src/Fieldtypes/HasSelectOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,6 @@ private function castToBoolean($value)
return true;
} elseif ($value === 'false') {
return false;
} elseif ($value === 'null') {
return null;
}

return $value;
Expand All @@ -162,8 +160,6 @@ private function castFromBoolean($value)
return 'true';
} elseif ($value === false) {
return 'false';
} elseif ($value === null) {
return 'null';
}

return $value;
Expand Down
Loading