Skip to content

[5.x] Add parent keyword to field conditions #9385

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 31 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
07ca566
feat: allow selection of parent level in field condition via `parent`…
florianbrinkmann Jan 24, 2024
901983b
Merge branch '4.x' into 783-add-possibility-to-access-parent-field-in…
florianbrinkmann Jan 24, 2024
f14c44b
Merge branch '5.x' into pr/9385
duncanmcclean May 14, 2024
2adc37b
Merge branch '5.x' into feature/add-parent-keyword-to-field-conditions
jasonvarga Jul 29, 2024
06d0515
Merge branch '5.x' of https://github.com/statamic/cms into feature/ad…
jesseleite Aug 16, 2024
ad02cbf
Port #9960’s `fieldPath` param into this PR.
jesseleite Aug 16, 2024
aad3289
Rename `root.` & `parent.` to `$root.` & `$parent.` (but still allow …
jesseleite Aug 16, 2024
c5a4d84
Update blueprint converter as well.
jesseleite Aug 16, 2024
800e6d5
Add test coverage and fix revealer issue.
jesseleite Aug 16, 2024
b40e21c
Technically these should match, though it doesn’t matter for tests.
jesseleite Aug 16, 2024
211840d
Escape dot.
jesseleite Aug 16, 2024
5d633ce
Add failing test for handing of `$parent.` in revealer condition logic.
jesseleite Aug 16, 2024
5c24c8c
Merge branch '5.x' of https://github.com/statamic/cms into feature/ad…
jesseleite Aug 21, 2024
c9f59d7
Kick test.
jesseleite Aug 21, 2024
b4e8de6
Unkick test.
jesseleite Sep 24, 2024
3f5a65a
Add failing test for `$parent` at multiple levels in values payload.
jesseleite Sep 26, 2024
d234df2
Extract and fix `$parent` resolving logic to pass test.
jesseleite Sep 26, 2024
d6e9068
These get treated as regex by my test runner, just word it better.
jesseleite Sep 26, 2024
b4a5315
Actually pass failing tests.
jesseleite Sep 26, 2024
7195c9d
Revealer tests wip.
jesseleite Sep 26, 2024
52bb8ae
Merge branch '5.x' of https://github.com/statamic/cms into feature/ad…
jesseleite Sep 26, 2024
92d1b5a
Flesh out `$parent` handling and test coverage (WIP).
jesseleite Sep 26, 2024
4b82755
Avoid regex symbols in test names for test runners.
jesseleite Sep 27, 2024
d5527b0
Okay, rip out all this in favour of `$parent` to `$root` resolving idea.
jesseleite Sep 27, 2024
bd82563
Spec it all out with unit tests.
jesseleite Sep 27, 2024
690ffa5
Make it all work for real.
jesseleite Sep 27, 2024
0e4f6f2
Resolve `$parent` to absolute `$root` field path (since that already …
jesseleite Sep 27, 2024
c21f55a
Now this all just works! 🎉🎉🎉
jesseleite Sep 27, 2024
5ea49a5
Flesh this out a little more to cover douple replicator with multiple…
jesseleite Sep 27, 2024
d3cc919
Failing test to show data loss because of improper `revealer` hidden …
jesseleite Sep 27, 2024
df8bf21
Prevent data loss with `$parent` syntax on revealers & pass failing t…
jesseleite Sep 27, 2024
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
10 changes: 8 additions & 2 deletions resources/js/components/field-conditions/Converter.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,17 @@ export default class {
}

getScopedFieldHandle(field, prefix) {
if (field.startsWith('root.') || ! prefix) {
if (field.startsWith('$root.') || field.startsWith('root.')) {
return field;
}

return prefix + field;
if (field.startsWith('$parent.')) {
return field;
}

return prefix
? prefix + field
: field;
}

getOperatorFromRhs(condition) {
Expand Down
42 changes: 42 additions & 0 deletions resources/js/components/field-conditions/ParentResolver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export default class {
constructor(currentFieldPath) {
this.currentFieldPath = currentFieldPath;
}

resolve(pathWithParent) {
let parentPath = this.getParentFieldPath(this.currentFieldPath, true);
let fieldPath = this.removeOneParentKeyword(pathWithParent);

while (fieldPath.startsWith('$parent.')) {
parentPath = this.getParentFieldPath(parentPath);
fieldPath = this.removeOneParentKeyword(fieldPath);
}

let resolved = parentPath
? `${parentPath}.${fieldPath}`
: fieldPath;

return `$root.${resolved}`;
}

getParentFieldPath(dottedFieldPath, removeCurrentField) {
const regex = new RegExp('(.*?[^\\.]+)(\\.[0-9]+)*\\.[^\\.]*$');

if (removeCurrentField || this.isAtSetLevel(dottedFieldPath)) {
dottedFieldPath = dottedFieldPath.replace(regex, '$1');
}

return dottedFieldPath.includes('.')
? dottedFieldPath.replace(regex, '$1$2')
: '';
}

isAtSetLevel(dottedFieldPath) {
return dottedFieldPath.match(new RegExp('(\\.[0-9]+)$'));
}

removeOneParentKeyword(dottedFieldPath) {
return dottedFieldPath.replace(new RegExp('^\\$parent.'), '');
}

}
33 changes: 23 additions & 10 deletions resources/js/components/field-conditions/Validator.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Converter from './Converter.js';
import ParentResolver from './ParentResolver.js';
import { KEYS } from './Constants.js';
import { data_get } from '../../bootstrap/globals.js'
import isString from 'underscore/modules/isString.js'
Expand All @@ -20,12 +21,13 @@ const NUMBER_SPECIFIC_COMPARISONS = [
];

export default class {
constructor(field, values, store, storeName) {
constructor(field, values, dottedFieldPath, store, storeName) {
this.field = field;
this.values = values;
this.rootValues = store ? store.state.publish[storeName].values : false;
this.dottedFieldPath = dottedFieldPath;
this.store = store;
this.storeName = storeName;
this.rootValues = store ? store.state.publish[storeName].values : false;
this.passOnAny = false;
this.showOnPass = true;
this.converter = new Converter;
Expand Down Expand Up @@ -204,9 +206,15 @@ export default class {
}

getFieldValue(field) {
return field.startsWith('root.')
? data_get(this.rootValues, field.replace(new RegExp('^root.'), ''))
: data_get(this.values, field);
if (field.startsWith('$parent.')) {
field = new ParentResolver(this.dottedFieldPath).resolve(field);
}

if (field.startsWith('$root.') || field.startsWith('root.')) {
return data_get(this.rootValues, field.replace(new RegExp('^\\$?root\\.'), ''));
}

return data_get(this.values, field);
}

passesCondition(condition) {
Expand Down Expand Up @@ -264,6 +272,7 @@ export default class {
root: this.rootValues,
store: this.store,
storeName: this.storeName,
fieldPath: this.dottedFieldPath,
});

return this.showOnPass ? passes : ! passes;
Expand All @@ -286,12 +295,16 @@ export default class {
}

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

if (lhs.startsWith('$root.') || lhs.startsWith('root.')) {
return lhs.replace(new RegExp('^\\$?root\\.'), '');
}

return lhs.startsWith('root.')
? lhs.replace(/^root\./, '')
: dottedPrefix + '.' + lhs;
return dottedPrefix
? dottedPrefix + '.' + lhs
: lhs;
}
}
2 changes: 1 addition & 1 deletion resources/js/components/field-conditions/ValidatorMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default {
}

// Use validation to determine whether field should be shown.
let validator = new Validator(field, this.values, this.$store, this.storeName);
let validator = new Validator(field, this.values, dottedFieldPath, this.$store, this.storeName);
let passes = validator.passesConditions();

// If the field is configured to always save, never omit value.
Expand Down
30 changes: 29 additions & 1 deletion resources/js/tests/FieldConditionsConverter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,21 @@ test('it converts from blueprint format and applies prefixes', () => {
expect(converted).toEqual(expected);
});

test('it converts from blueprint format and does not apply prefix to root field conditions', () => {
test('it converts from blueprint format and does not apply prefix to field conditions with root syntax', () => {
let converted = FieldConditionsConverter.fromBlueprint({
'name': 'isnt joe',
'$root.title': 'not empty',
}, 'nested_');

let expected = [
{field: 'nested_name', operator: 'not', value: 'joe'},
{field: '$root.title', operator: 'not', value: 'empty'}
];

expect(converted).toEqual(expected);
});

test('it converts from blueprint format and does not apply prefix to field conditions with legacy root syntax for backwards compatibility', () => {
let converted = FieldConditionsConverter.fromBlueprint({
'name': 'isnt joe',
'root.title': 'not empty',
Expand All @@ -49,6 +63,20 @@ test('it converts from blueprint format and does not apply prefix to root field
expect(converted).toEqual(expected);
});

test('it converts from blueprint format and does not apply prefix to field conditions with parent syntax', () => {
let converted = FieldConditionsConverter.fromBlueprint({
'name': 'isnt joe',
'$parent.title': 'not empty',
}, 'nested_');

let expected = [
{field: 'nested_name', operator: 'not', value: 'joe'},
{field: '$parent.title', operator: 'not', value: 'empty'}
];

expect(converted).toEqual(expected);
});

test('it converts to blueprint format', () => {
let converted = FieldConditionsConverter.toBlueprint([
{field: 'name', operator: 'isnt', value: 'joe'},
Expand Down
68 changes: 68 additions & 0 deletions resources/js/tests/FieldConditionsParentResolver.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import ParentResolver from '../components/field-conditions/ParentResolver.js';

let resolve = function (currentFieldPath, pathWithParent) {
return new ParentResolver(currentFieldPath).resolve(pathWithParent);
}

test('it resolves from group to top level', () => {
expect(resolve('group.field', '$parent.name')).toEqual('$root.name');
});

test('it resolves from set/row to top level', () => {
expect(resolve('replicator.0.field', '$parent.name')).toEqual('$root.name');
expect(resolve('grid.0.field', '$parent.name')).toEqual('$root.name');
expect(resolve('bard.0.field', '$parent.name')).toEqual('$root.name');
});

test('it resolves from nested group to parent group', () => {
expect(resolve('group.nested_group.field', '$parent.name')).toEqual('$root.group.name');
});

test('it resolves from nested set to parent set', () => {
expect(resolve('replicator.2.nested_replicator.1.field', '$parent.name')).toEqual('$root.replicator.2.name');
expect(resolve('grid.2.nested_grid.1.field', '$parent.name')).toEqual('$root.grid.2.name');
expect(resolve('bard.2.nested_bard.1.field', '$parent.name')).toEqual('$root.bard.2.name');
});

test('it resolves from nested group to parent set', () => {
expect(resolve('replicator.1.group.field', '$parent.name')).toEqual('$root.replicator.1.name');
expect(resolve('grid.1.group.field', '$parent.name')).toEqual('$root.grid.1.name');
expect(resolve('bard.1.group.field', '$parent.name')).toEqual('$root.bard.1.name');
});

test('it resolves from nested set to parent group', () => {
expect(resolve('group.replicator.1.field', '$parent.name')).toEqual('$root.group.name');
expect(resolve('group.grid.1.field', '$parent.name')).toEqual('$root.group.name');
expect(resolve('group.bard.1.field', '$parent.name')).toEqual('$root.group.name');
});

test('it resolves from deeply nested groups all the way up to top level', () => {
let fromField = 'group.nested_group.deeper_group.deeeeeeeper_group.field';

expect(resolve(fromField, '$parent.name')).toEqual('$root.group.nested_group.deeper_group.name');
expect(resolve(fromField, '$parent.$parent.name')).toEqual('$root.group.nested_group.name');
expect(resolve(fromField, '$parent.$parent.$parent.name')).toEqual('$root.group.name');
expect(resolve(fromField, '$parent.$parent.$parent.$parent.name')).toEqual('$root.name');
});

test('it resolves from deeply nested sets all the way up to top level', () => {
let fromField = 'replicator.1.bard.4.grid.0.replicator.6.field';

expect(resolve(fromField, '$parent.name')).toEqual('$root.replicator.1.bard.4.grid.0.name');
expect(resolve(fromField, '$parent.$parent.name')).toEqual('$root.replicator.1.bard.4.name');
expect(resolve(fromField, '$parent.$parent.$parent.name')).toEqual('$root.replicator.1.name');
expect(resolve(fromField, '$parent.$parent.$parent.$parent.name')).toEqual('$root.name');
});

test('it resolves from deeply nested mix of everything all the way up to top level', () => {
let fromField = 'group.replicator.1.group.bard.4.grid.0.group.group.replicator.6.field';

expect(resolve(fromField, '$parent.name')).toEqual('$root.group.replicator.1.group.bard.4.grid.0.group.group.name');
expect(resolve(fromField, '$parent.$parent.name')).toEqual('$root.group.replicator.1.group.bard.4.grid.0.group.name');
expect(resolve(fromField, '$parent.$parent.$parent.name')).toEqual('$root.group.replicator.1.group.bard.4.grid.0.name');
expect(resolve(fromField, '$parent.$parent.$parent.$parent.name')).toEqual('$root.group.replicator.1.group.bard.4.name');
expect(resolve(fromField, '$parent.$parent.$parent.$parent.$parent.name')).toEqual('$root.group.replicator.1.group.name');
expect(resolve(fromField, '$parent.$parent.$parent.$parent.$parent.$parent.name')).toEqual('$root.group.replicator.1.name');
expect(resolve(fromField, '$parent.$parent.$parent.$parent.$parent.$parent.$parent.name')).toEqual('$root.group.name');
expect(resolve(fromField, '$parent.$parent.$parent.$parent.$parent.$parent.$parent.$parent.name')).toEqual('$root.name');
});
Loading
Loading