-
-
Notifications
You must be signed in to change notification settings - Fork 20
Preserve nested datatable values on ML locale switch/copy (#33) #117
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| <?php | ||
|
|
||
| use Winter\Translate\Traits\MLControl; | ||
|
|
||
| /** | ||
| * Covers the datatable postback-handler injection that keeps a datatable's client-memory | ||
| * data from being lost when an enclosing multilingual widget switches locale. | ||
| * | ||
| * @see https://github.com/wintercms/wn-translate-plugin/issues/33 | ||
| */ | ||
| class MLControlDatatableHandlersTest extends \Winter\Translate\Tests\TranslatePluginTestCase | ||
| { | ||
| protected function makeControl() | ||
| { | ||
| return new class { | ||
| use MLControl; | ||
|
|
||
| // Expose the protected helpers for testing. | ||
| public function apply(array $fields, array $handlers): array | ||
| { | ||
| return $this->applyLocaleDatatableHandlers($fields, $handlers); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| public function test_injects_handlers_into_a_datatable_field() | ||
| { | ||
| $handlers = ['w::onSwitchItemLocale', 'w::onCopyItemLocale']; | ||
|
|
||
| $out = $this->makeControl()->apply([ | ||
| 'title' => ['type' => 'text'], | ||
| 'specs' => ['type' => 'datatable'], | ||
| ], $handlers); | ||
|
|
||
| // Non-datatable field is untouched. | ||
| $this->assertArrayNotHasKey('postbackHandlerName', $out['title']); | ||
|
|
||
| // Datatable field gains the handlers, keeping the default onSave. | ||
| $names = explode(',', $out['specs']['postbackHandlerName']); | ||
| $this->assertContains('onSave', $names); | ||
| $this->assertContains('w::onSwitchItemLocale', $names); | ||
| $this->assertContains('w::onCopyItemLocale', $names); | ||
| } | ||
|
|
||
| public function test_preserves_and_dedupes_existing_handlers() | ||
| { | ||
| $out = $this->makeControl()->apply([ | ||
| 'specs' => ['type' => 'datatable', 'postbackHandlerName' => 'onSave,onCustom'], | ||
| ], ['w::onSwitchItemLocale', 'onCustom']); | ||
|
|
||
| $names = explode(',', $out['specs']['postbackHandlerName']); | ||
| $this->assertSame(['onSave', 'onCustom', 'w::onSwitchItemLocale'], $names); | ||
| } | ||
|
|
||
| public function test_injects_into_nested_form_datatables() | ||
| { | ||
| $handlers = ['w::onSwitchItemLocale', 'w::onCopyItemLocale']; | ||
|
|
||
| $out = $this->makeControl()->apply([ | ||
| 'contacts' => [ | ||
| 'type' => 'nestedform', | ||
| 'form' => ['fields' => [ | ||
| 'inner_specs' => ['type' => 'datatable'], | ||
| 'inner_text' => ['type' => 'text'], | ||
| ]], | ||
| ], | ||
| ], $handlers); | ||
|
|
||
| $inner = $out['contacts']['form']['fields']; | ||
| $this->assertStringContainsString('w::onSwitchItemLocale', $inner['inner_specs']['postbackHandlerName']); | ||
| $this->assertArrayNotHasKey('postbackHandlerName', $inner['inner_text']); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -280,4 +280,125 @@ protected function objectMethodExists($object, $method) | |
|
|
||
| return method_exists($object, $method); | ||
| } | ||
|
|
||
| /** | ||
| * Ensures any datatable form widget nested inside this multilingual widget posts | ||
| * its client-memory data during the locale switch/copy AJAX requests, not only on | ||
| * the form's save handler. Without this the datatable's values are dropped when the | ||
| * locale changes, since the Table widget only serialises its data for handlers | ||
| * listed in its `postbackHandlerName` (default `onSave`). | ||
| * | ||
| * This injects this widget's namespaced `onSwitchItemLocale` / `onCopyItemLocale` | ||
| * handlers into the `postbackHandlerName` of every nested datatable field, relying | ||
| * on the Table widget's list support (winter/winter#560). | ||
| * | ||
| * Must run before the inner form is built (i.e. before parent::init()). | ||
| * | ||
| * @see https://github.com/wintercms/wn-translate-plugin/issues/33 | ||
| * @return void | ||
| */ | ||
| protected function registerLocaleDatatableHandlers() | ||
| { | ||
| if (!isset($this->config)) { | ||
| return; | ||
| } | ||
|
|
||
| $handlers = [ | ||
| $this->getEventHandler('onSwitchItemLocale'), | ||
| $this->getEventHandler('onCopyItemLocale'), | ||
| ]; | ||
|
|
||
| if (isset($this->config->form)) { | ||
| $this->config->form = $this->addLocaleDatatableHandlers( | ||
| $this->normalizeFormConfig($this->config->form), | ||
| $handlers | ||
| ); | ||
| } | ||
|
|
||
| if (isset($this->config->groups)) { | ||
| $groups = $this->normalizeFormConfig($this->config->groups); | ||
| foreach ($groups as $code => $group) { | ||
| if (is_array($group)) { | ||
| $groups[$code] = $this->addLocaleDatatableHandlers($group, $handlers); | ||
| } | ||
| } | ||
| $this->config->groups = $groups; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Resolves a form/groups config (inline array, config object, or `$/path` reference) | ||
| * into a plain array so its field definitions can be inspected and modified. | ||
| * | ||
| * @param mixed $config | ||
| * @return array | ||
| */ | ||
| protected function normalizeFormConfig($config) | ||
| { | ||
| if (is_string($config) && $config !== '') { | ||
| $config = $this->makeConfig($config); | ||
| } | ||
|
|
||
| if (is_object($config)) { | ||
| $config = json_decode(json_encode($config), true); | ||
| } | ||
|
|
||
| return is_array($config) ? $config : []; | ||
| } | ||
|
|
||
| /** | ||
| * Appends the given AJAX handlers to the `postbackHandlerName` of every datatable | ||
| * field within a form config, descending into nested form definitions. | ||
| * | ||
| * @param array $form | ||
| * @param string[] $handlers | ||
| * @return array | ||
| */ | ||
| protected function addLocaleDatatableHandlers(array $form, array $handlers) | ||
| { | ||
| if (isset($form['fields']) && is_array($form['fields'])) { | ||
| $form['fields'] = $this->applyLocaleDatatableHandlers($form['fields'], $handlers); | ||
| } | ||
|
|
||
| foreach (['tabs', 'secondaryTabs'] as $tabKey) { | ||
| if (isset($form[$tabKey]['fields']) && is_array($form[$tabKey]['fields'])) { | ||
| $form[$tabKey]['fields'] = $this->applyLocaleDatatableHandlers($form[$tabKey]['fields'], $handlers); | ||
| } | ||
| } | ||
|
|
||
| return $form; | ||
| } | ||
|
|
||
| /** | ||
| * @param array $fields | ||
| * @param string[] $handlers | ||
| * @return array | ||
| */ | ||
| protected function applyLocaleDatatableHandlers(array $fields, array $handlers) | ||
| { | ||
| foreach ($fields as $name => $config) { | ||
| if (!is_array($config)) { | ||
| continue; | ||
| } | ||
|
|
||
| if (($config['type'] ?? null) === 'datatable') { | ||
| $existing = $config['postbackHandlerName'] ?? 'onSave'; | ||
| $list = is_array($existing) | ||
| ? $existing | ||
| : array_map('trim', explode(',', (string) $existing)); | ||
| $config['postbackHandlerName'] = implode(',', array_values(array_unique( | ||
| array_merge(array_filter($list), $handlers) | ||
| ))); | ||
| } | ||
|
|
||
| // Descend into nested form definitions (nested form / repeater sub-fields). | ||
| if (isset($config['form']) && is_array($config['form'])) { | ||
| $config['form'] = $this->addLocaleDatatableHandlers($config['form'], $handlers); | ||
| } | ||
|
Comment on lines
+395
to
+397
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the trait structure before reading the affected implementation.
ast-grep outline traits/MLControl.php --items all
# Find nested form definitions that use external YAML references.
rg -n -C 4 --glob '*.yaml' --glob '*.yml' \
'^[[:space:]]*form:[[:space:]]*\$/' .
# Inspect the normalization and nested-form traversal paths.
rg -n -C 5 --glob '*.php' \
'normalizeFormConfig|addLocaleDatatableHandlers|applyLocaleDatatableHandlers' \
traits formwidgets testsRepository: wintercms/wn-translate-plugin Length of output: 802 🏁 Script executed: #!/bin/bash
set +e
echo '--- MLControl.php targeted implementation ---'
cat -n traits/MLControl.php | sed -n '250,430p'
echo '--- MLControl.php method references ---'
rg -n -C 6 'normalizeFormConfig|registerLocaleDatatableHandlers|addLocaleDatatableHandlers|applyLocaleDatatableHandlers' traits tests formwidgets . --glob '*.php' 2>/dev/null
echo '--- form references in repository configuration ---'
rg -n -C 5 --glob '*.yaml' --glob '*.yml' --glob '*.php' \
'form:[[:space:]]*(\$|[A-Za-z_])|form[[:space:]]*=>[[:space:]]*(\$|[A-Za-z_])' . 2>/dev/null
echo '--- relevant tests and files ---'
git ls-files | rg '(^|/)(tests?|formwidgets|traits)/|MLControl|locale|datatable' | head -200Repository: wintercms/wn-translate-plugin Length of output: 25763 🏁 Script executed: #!/bin/bash
set +e
echo '--- existing datatable handler tests ---'
cat -n tests/unit/traits/MLControlDatatableHandlersTest.php
echo '--- dependency and project metadata ---'
cat -n composer.json | sed -n '1,220p'
rg -n -C 4 'NestedForm|Repeater|postbackHandlerName|makeConfig|config->form|form.*\\$/' README.md docs tests controllers models formwidgets traits composer.json .github 2>/dev/null
echo '--- all nested form key assignments in tracked source ---'
rg -n -C 3 --glob '*.php' --glob '*.yaml' --glob '*.yml' --glob '*.json' \
"'form'|\"form\"|^[[:space:]]*form:" . 2>/dev/nullRepository: wintercms/wn-translate-plugin Length of output: 43549 🌐 Web query:
💡 Result: In Winter CMS, the NestedForm form widget allows you to render a nested form within a field, storing the data as a JSON array [1][2]. Configuration Object and makeConfig The makeConfig method is part of the System\Traits\ConfigMaker trait, which is utilized by backend controllers, widgets, and form behaviors to process configuration files [3][4][5]. It is not specific to the NestedForm widget, but rather a core mechanism for handling configuration across the Winter CMS backend [3]. When defining forms in Winter CMS, you typically use a YAML configuration file (e.g., config_form.yaml) or a PHP array [4]. The makeConfig method takes these definitions and converts them into a configuration object (or array) that the system can use [3]. Key details regarding this process: 1. System\Traits\ConfigMaker: This trait provides makeConfig(array $configFile = [], array $requiredConfig = []) [3]. It reads the supplied file or array and applies it to the object, ensuring required configuration keys are present [3]. 2. Backend Form Configuration: When you define a nested form, the form property within the NestedForm field definition contains the nested form's specific structure (fields, tabs, etc.) [1][2]. This structure follows the same syntax as standard backend forms [1][2]. 3. Implementation: While you generally define your form structure in YAML, the underlying system uses these methods to parse and build the form widgets [4][6]. If you are working within a custom class or extending existing functionality, you can use $this->makeConfig to process your own configuration files [3][4]. NestedForm Specifics To use the NestedForm widget, define the field type as nestedform and provide a form definition [1][2]: content: type: nestedform usePanelStyles: false form: fields: title: label: Title type: text For this to function correctly, the model attribute bound to the field must be a jsonable type or otherwise capable of storing array data [1][2]. Citations:
🌐 Web query:
💡 Result: In Winter CMS, NestedForm.php refers to the backend form widget used to embed a secondary form within a primary form [1]. The method Citations:
🌐 Web query:
💡 Result: There is no standard class named ConfigMaker.php or a function called makeConfig in the core Winter CMS framework. It appears you are likely looking for the Winter Laravel Config Writer library, which was originally part of the Storm library in Winter CMS and later extracted as a standalone utility [1][2][3]. This library is used to programmatically create and modify Laravel-style PHP configuration files while preserving formatting and comments [1][4]. In the Winter Laravel Config Writer library, the class used to manipulate PHP array-based configuration files is Winter\LaravelConfigWriter\ArrayFile [1][4]. Key usage patterns for this library include: 1. Opening a file: Use ArrayFile::open to open an existing configuration file or to create a new one [1][2]. use Winter\LaravelConfigWriter\ArrayFile; $config = ArrayFile::open(base_path('config/app.php')); 2. Setting values: Use the set method to modify keys and values [1][3]. $config->set('debug', false); 3. Adding function calls: You can add PHP function calls to your configuration file using the function method [2][4]. $config->function('env', ['APP_DEBUG', true]); 4. Finalizing changes: Always call the write method to save the changes to the file [1][2]. $config->write; If you are trying to manage environment variables specifically, Winter CMS provides the php artisan winter:env command, which helps convert configuration files to use.env variables [5]. Citations:
🌐 Web query:
💡 Result: The term NestedForm refers to a specific form field type used in the Winter CMS backend development framework [1][2]. It allows developers to render a secondary, nested form directly within a primary form [1][2]. Key characteristics include: - Functionality: It collates reusable fields and makes them available across multiple forms [1]. The data submitted through the nested form is returned as an array [1][2]. - Storage: To store this data, the model attribute must be capable of handling arrayed data, such as a jsonable attribute [1]. - Configuration: A nested form supports standard form syntax, including the use of tabs, secondary tabs, and outside fields [1]. - Recursion: It is possible to nest forms within other nested forms [1]. - Options: Common configuration options include form (to define the form structure) and usePanelStyles (a boolean determining if the nested form should be wrapped in a panel container, defaulting to true) [1]. Example configuration: content: type: nestedform usePanelStyles: false form: fields: added_at: label: Date added type: datepicker details: label: Details type: textarea title: label: This is the title type: text Citations:
🏁 Script executed: #!/bin/bash
set +e
echo '--- locate upstream NestedForm and config-maker sources ---'
curl -fsSL 'https://api.github.com/repos/wintercms/winter/git/trees/develop?recursive=1' |
jq -r '.tree[]?.path' |
rg '(^|/)(NestedForm|WidgetBase|ConfigMaker)\.php$|formwidgets/.*/.*\.php$' |
head -80
echo '--- inspect likely upstream source files ---'
for url in \
'https://raw.githubusercontent.com/wintercms/winter/develop/modules/backend/formwidgets/NestedForm.php' \
'https://raw.githubusercontent.com/wintercms/winter/develop/modules/backend/formwidgets/nestedform/NestedForm.php' \
'https://raw.githubusercontent.com/wintercms/winter/develop/modules/backend/classes/WidgetBase.php' \
'https://raw.githubusercontent.com/wintercms/winter/develop/modules/system/traits/ConfigMaker.php'
do
echo "URL: $url"
curl -fsSL "$url" 2>/dev/null |
rg -n -C 5 'makeConfig|form|config' |
head -100
done
echo '--- standalone branch-behavior probe ---'
python3 - <<'PY'
HANDLERS = ['switch', 'copy']
def inject(fields):
result = {}
for name, config in fields.items():
if not isinstance(config, dict):
result[name] = config
continue
config = dict(config)
if config.get('type') == 'datatable':
existing = config.get('postbackHandlerName', 'onSave')
names = existing if isinstance(existing, list) else [
item.strip() for item in str(existing).split(',')
]
config['postbackHandlerName'] = ','.join(
dict.fromkeys([item for item in names if item] + HANDLERS)
)
if isinstance(config.get('form'), dict):
config['form'] = add_form(config['form'])
result[name] = config
return result
def add_form(form):
form = dict(form)
if isinstance(form.get('fields'), dict):
form['fields'] = inject(form['fields'])
return form
def normalize(value):
if isinstance(value, str):
return {'fields': {'inner_table': {'type': 'datatable'}}}
if hasattr(value, '__dict__'):
return normalize(value.__dict__)
return value if isinstance(value, dict) else {}
def proposed(fields):
result = {}
for name, config in fields.items():
if not isinstance(config, dict):
result[name] = config
continue
config = dict(config)
if isinstance(config.get('form'), (dict, str)) or hasattr(config.get('form'), '__dict__'):
nested = normalize(config['form'])
if nested:
config['form'] = add_form(nested)
result[name] = config
return result
class ConfigObject:
def __init__(self):
self.fields = {'inner_table': {'type': 'datatable'}}
cases = {
'inline array': {'field': {'form': {'fields': {'inner_table': {'type': 'datatable'}}}}},
'string reference': {'field': {'form': '$/plugin/fields.yaml'}},
'configuration object': {'field': {'form': ConfigObject()}},
}
for label, fields in cases.items():
current = inject(fields)
updated = proposed(fields)
print(label)
print(' current:', current['field']['form'])
print(' proposed:', updated['field']['form'])
PYRepository: wintercms/wn-translate-plugin Length of output: 12151 🏁 Script executed: #!/bin/bash
set +e
echo '--- locate upstream NestedForm and config-maker sources ---'
curl -fsSL 'https://api.github.com/repos/wintercms/winter/git/trees/develop?recursive=1' |
jq -r '.tree[]?.path' |
rg '(^|/)(NestedForm|WidgetBase|ConfigMaker)\.php$|formwidgets/.*/.*\.php$' |
head -80
echo '--- inspect likely upstream source files ---'
for url in \
'https://raw.githubusercontent.com/wintercms/winter/develop/modules/backend/formwidgets/NestedForm.php' \
'https://raw.githubusercontent.com/wintercms/winter/develop/modules/backend/formwidgets/nestedform/NestedForm.php' \
'https://raw.githubusercontent.com/wintercms/winter/develop/modules/backend/classes/WidgetBase.php' \
'https://raw.githubusercontent.com/wintercms/winter/develop/modules/system/traits/ConfigMaker.php'
do
echo "URL: $url"
curl -fsSL "$url" 2>/dev/null |
rg -n -C 5 'makeConfig|form|config' |
head -100
done
echo '--- standalone branch-behavior probe ---'
python3 - <<'PY'
HANDLERS = ['switch', 'copy']
def apply(fields, handlers):
result = {}
for name, config in fields.items():
if not isinstance(config, dict):
result[name] = config
continue
config = dict(config)
if config.get('type') == 'datatable':
existing = config.get('postbackHandlerName', 'onSave')
names = existing if isinstance(existing, list) else [
item.strip() for item in str(existing).split(',')
]
config['postbackHandlerName'] = ','.join(
dict.fromkeys([item for item in names if item] + handlers)
)
if isinstance(config.get('form'), dict):
config['form'] = add_form(config['form'])
result[name] = config
return result
def add_form(form):
form = dict(form)
if isinstance(form.get('fields'), dict):
form['fields'] = apply(form['fields'], HANDLERS)
return form
def normalize(value):
if isinstance(value, str):
return {'fields': {'inner_table': {'type': 'datatable'}}}
if hasattr(value, '__dict__'):
return normalize(value.__dict__)
return value if isinstance(value, dict) else {}
def proposed(fields):
result = {}
for name, config in fields.items():
if not isinstance(config, dict):
result[name] = config
continue
config = dict(config)
if 'form' in config:
nested = normalize(config['form'])
if nested:
config['form'] = add_form(nested)
result[name] = config
return result
class ConfigObject:
def __init__(self):
self.fields = {'inner_table': {'type': 'datatable'}}
cases = {
'inline array': {'field': {'form': {'fields': {'inner_table': {'type': 'datatable'}}}}},
'string reference': {'field': {'form': '$/plugin/fields.yaml'}},
'configuration object': {'field': {'form': ConfigObject()}},
}
for label, fields in cases.items():
current = apply(fields, HANDLERS)
updated = proposed(fields)
print(label)
print(' current:', current['field']['form'])
print(' proposed:', updated['field']['form'])
PYRepository: wintercms/wn-translate-plugin Length of output: 12151 Normalize nested
🤖 Prompt for AI Agents |
||
|
|
||
| $fields[$name] = $config; | ||
| } | ||
|
|
||
| return $fields; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Assert the complete handler set for nested datatables.
The nested test checks only
w::onSwitchItemLocale. It would pass ifw::onCopyItemLocalewere omitted or if the defaultonSavehandler were lost. Assert all three handlers in$inner['inner_specs']['postbackHandlerName'].Suggested assertions
📝 Committable suggestion
🤖 Prompt for AI Agents