From ec470f6b2388e7cd86cfaa2c443421191f1d9a91 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 31 Dec 2025 19:45:24 -0600 Subject: [PATCH 1/3] Simplify migration code generation & generate anonymous class migrations --- behaviors/IndexDatabaseTableOperations.php | 17 ++++++---- classes/DatabaseTableModel.php | 32 ++++++++++++++++--- classes/TableMigrationCodeGenerator.php | 21 ++++++------ .../templates/full-migration-code.php.tpl | 19 ++++++++--- .../templates/migration-code.php.tpl | 9 ------ 5 files changed, 60 insertions(+), 38 deletions(-) delete mode 100644 classes/databasetablemodel/templates/migration-code.php.tpl diff --git a/behaviors/IndexDatabaseTableOperations.php b/behaviors/IndexDatabaseTableOperations.php index 626d22f..c1eef49 100644 --- a/behaviors/IndexDatabaseTableOperations.php +++ b/behaviors/IndexDatabaseTableOperations.php @@ -82,19 +82,22 @@ public function onDatabaseTableMigrationApply() $model = new MigrationModel(); $model->setPluginCodeObj($pluginCode); + // Fill all fields from the form - code is already wrapped from DatabaseTableModel $model->fill([ 'version' => Request::input('version'), 'description' => Request::input('description'), + 'code' => Request::input('code'), ]); + // The scriptFileName should be extracted from the code by MigrationModel::assignFileName() + // But as a safety fallback, generate it if needed $operation = Input::get('operation'); $table = Input::get('table'); - $model->scriptFileName = 'builder_table_'.$operation.'_'.$table; - $model->makeScriptFileNameUnique(); - - $codeGenerator = new TableMigrationCodeGenerator(); - $model->code = $codeGenerator->wrapMigrationCode($model->scriptFileName, Request::input('code'), $pluginCode); + if (!$model->scriptFileName) { + $model->scriptFileName = 'builder_table_'.$operation.'_'.$table; + $model->makeScriptFileNameUnique(); + } try { $model->save(); @@ -190,12 +193,12 @@ protected function loadOrCreateBaseModel($tableName, $options = []) return $model; } - protected function makeMigrationFormWidget($migration) + protected function makeMigrationFormWidget($migration, $alias = null) { $widgetConfig = $this->makeConfig($this->migrationFormConfigFile); $widgetConfig->model = $migration; - $widgetConfig->alias = 'form_migration_'.uniqid(); + $widgetConfig->alias = $alias ?: 'form_migration_'.uniqid().'_'; $form = $this->makeWidget('Backend\Widgets\Form', $widgetConfig); $form->context = FormController::CONTEXT_CREATE; diff --git a/classes/DatabaseTableModel.php b/classes/DatabaseTableModel.php index 88a26e5..94166a7 100644 --- a/classes/DatabaseTableModel.php +++ b/classes/DatabaseTableModel.php @@ -168,8 +168,14 @@ public function generateCreateOrUpdateMigration() return $migrationCode; } + $operation = $existingSchema ? 'update' : 'create'; $description = $existingSchema ? 'Updated table %s' : 'Created table %s'; - return $this->createMigrationObject($migrationCode, sprintf($description, $tableName)); + return $this->createMigrationObject( + $migrationCode, + sprintf($description, $tableName), + $operation, + $tableName + ); } public function generateDropMigration() @@ -178,7 +184,12 @@ public function generateDropMigration() $codeGenerator = new TableMigrationCodeGenerator(); $migrationCode = $codeGenerator->dropTable($existingSchema); - return $this->createMigrationObject($migrationCode, sprintf('Drop table %s', $this->name)); + return $this->createMigrationObject( + $migrationCode, + sprintf('Drop table %s', $this->name), + 'delete', + $this->name + ); } public static function getSchema() @@ -420,15 +431,26 @@ protected function loadColumnsFromTableInfo() } } - protected function createMigrationObject($code, $description) + protected function createMigrationObject($code, $description, $operation, $tableName) { $migration = new MigrationModel(); $migration->setPluginCodeObj($this->getPluginCodeObj()); - - $migration->code = $code; $migration->version = $migration->getNextVersion(); $migration->description = $description; + // Generate script file name early so we can use it for wrapping + $migration->scriptFileName = 'builder_table_' . $operation . '_' . $tableName; + $migration->makeScriptFileNameUnique(); + + // Wrap the up/down methods with full migration class structure + $codeGenerator = new TableMigrationCodeGenerator(); + $migration->code = $codeGenerator->wrapMigrationCode( + $migration->scriptFileName, + $code['upCode'], + $code['downCode'], + $this->getPluginCodeObj() + ); + return $migration; } } diff --git a/classes/TableMigrationCodeGenerator.php b/classes/TableMigrationCodeGenerator.php index bc10bde..8c747fc 100644 --- a/classes/TableMigrationCodeGenerator.php +++ b/classes/TableMigrationCodeGenerator.php @@ -77,11 +77,12 @@ public function createOrUpdateTable($updatedTable, $existingTable, $newTableName /** * Wrap migration's up() and down() functions into a complete migration class declaration * @param string $scriptFilename Specifies the migration script file name - * @param string $code Specifies the migration code + * @param string $upCode Specifies the up() method code + * @param string $downCode Specifies the down() method code * @param PluginCode $pluginCodeObj The plugin code object * @return string */ - public function wrapMigrationCode($scriptFilename, $code, $pluginCodeObj) + public function wrapMigrationCode($scriptFilename, $upCode, $downCode, $pluginCodeObj) { $templatePath = '$/winter/builder/classes/databasetablemodel/templates/full-migration-code.php.tpl'; $templatePath = File::symbolizePath($templatePath); @@ -89,9 +90,8 @@ public function wrapMigrationCode($scriptFilename, $code, $pluginCodeObj) $fileContents = File::get($templatePath); return TextParser::parse($fileContents, [ - 'className' => Str::studly($scriptFilename), - 'migrationCode' => $this->indent($code), - 'namespace' => $pluginCodeObj->toUpdatesNamespace() + 'upCode' => $this->indent($upCode), + 'downCode' => $this->indent($downCode) ]); } @@ -124,15 +124,12 @@ protected function generateCreateOrUpdateCode($tableDiff, $isNewTable, $newOrUpd protected function generateMigrationCode($upCode, $downCode) { - $templatePath = '$/winter/builder/classes/databasetablemodel/templates/migration-code.php.tpl'; - $templatePath = File::symbolizePath($templatePath); - - $fileContents = File::get($templatePath); - - return TextParser::parse($fileContents, [ + // Return an array of up/down codes to be wrapped later + // The unwrapped template is no longer needed + return [ 'upCode' => $upCode, 'downCode' => $downCode - ]); + ]; } protected function generateCreateOrUpdateUpCode($tableDiff, $isNewTable, $newOrUpdatedTable) diff --git a/classes/databasetablemodel/templates/full-migration-code.php.tpl b/classes/databasetablemodel/templates/full-migration-code.php.tpl index f6b228c..4939ba0 100644 --- a/classes/databasetablemodel/templates/full-migration-code.php.tpl +++ b/classes/databasetablemodel/templates/full-migration-code.php.tpl @@ -1,9 +1,18 @@ - Date: Wed, 31 Dec 2025 19:45:49 -0600 Subject: [PATCH 2/3] Support new monaco code editor provided by Winter --- assets/css/builder.css | 34 ++++++------- assets/js/build-min.js | 20 ++++---- .../js/builder.index.entity.localization.js | 48 +++++++++++++------ assets/less/localization.less | 13 +++++ 4 files changed, 74 insertions(+), 41 deletions(-) diff --git a/assets/css/builder.css b/assets/css/builder.css index 819ceb8..9a094a4 100644 --- a/assets/css/builder.css +++ b/assets/css/builder.css @@ -34,7 +34,7 @@ .builder-building-area ul.builder-control-list>li.control>.control-static-contents{position:relative;-webkit-transition:margin 0.1s;transition:margin 0.1s} .builder-building-area ul.builder-control-list>li.placeholder:hover, .builder-building-area ul.builder-control-list>li.placeholder.popover-highlight, -.builder-building-area ul.builder-control-list>li.placeholder.control-palette-open{background-color:#2581b8 !important;color:white !important;border-style:solid;border-color:#2581b8} +.builder-building-area ul.builder-control-list>li.placeholder.control-palette-open{background-color:#2581b8 !important;color:white!important;border-style:solid;border-color:#2581b8} .builder-building-area ul.builder-control-list>li.control:not(.placeholder):not(.loading-control):not([data-unknown]):hover>.control-wrapper *, .builder-building-area ul.builder-control-list>li.control.inspector-open:not(.placeholder):not(.loading-control)>.control-wrapper *{color:#2581b8 !important} .builder-building-area ul.builder-control-list>li.control.drag-over:not(.placeholder):before{position:absolute;content:'';top:0;left:0;width:10px;height:100%;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;background-color:#2581b8} @@ -100,9 +100,9 @@ html.gecko.mac .builder-building-area div[data-root-control-wrapper]{margin-righ .builder-building-area li.inspector-open>.control-wrapper .builder-blueprint-control-switch:before{background-color:#2581b8} .builder-building-area .builder-blueprint-control-repeater-body>.repeater-button{padding:8px 13px;background:#bdc3c7;color:white;display:inline-block;margin-bottom:10px;-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px} .builder-building-area ul.builder-control-list>li.control:hover>.control-wrapper>.control-body .builder-blueprint-control-repeater-body>.repeater-button, -.builder-building-area ul.builder-control-list>li.inspector-open>.control-wrapper>.control-body .builder-blueprint-control-repeater-body>.repeater-button{background:#2581b8;color:white !important} +.builder-building-area ul.builder-control-list>li.inspector-open>.control-wrapper>.control-body .builder-blueprint-control-repeater-body>.repeater-button{background:#2581b8;color:white!important} .builder-building-area ul.builder-control-list>li.control:hover>.control-wrapper>.control-body .builder-blueprint-control-repeater-body>.repeater-button span, -.builder-building-area ul.builder-control-list>li.inspector-open>.control-wrapper>.control-body .builder-blueprint-control-repeater-body>.repeater-button span{color:white !important} +.builder-building-area ul.builder-control-list>li.inspector-open>.control-wrapper>.control-body .builder-blueprint-control-repeater-body>.repeater-button span{color:white!important} .builder-building-area .builder-blueprint-control-repeater{position:relative} .builder-building-area .builder-blueprint-control-repeater:before{content:'';position:absolute;width:2px;top:0;left:2px;height:100%;background:#bdc3c7} .builder-building-area .builder-blueprint-control-repeater:after{content:'';position:absolute;width:6px;height:6px;top:14px;left:0;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;background:#bdc3c7} @@ -171,7 +171,7 @@ html.gecko.mac .builder-controllers-builder-area ul.controller-behavior-list{pad .builder-tabs>.tabs .tab-control.inspector-trigger span{display:block;width:3px;height:3px;margin-bottom:2px;background:#95a5a6} .builder-tabs>.tabs .tab-control.inspector-trigger span:last-child{margin-bottom:0} .builder-tabs>.tabs .tab-control.inspector-trigger:hover span, -.builder-tabs>.tabs .tab-control.inspector-trigger.inspector-open span{background:#0181b9} +.builder-tabs>.tabs .tab-control.inspector-trigger.inspector-open span{background:#2da7c7} .builder-tabs>.tabs .tab-control.inspector-trigger.global{top:5px;right:15px} .builder-tabs>.tabs>ul.tabs{margin:0;list-style:none;font-size:0;white-space:nowrap;overflow:hidden;position:relative} .builder-tabs>.tabs>ul.tabs>li{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;display:inline-block;font-size:13px;white-space:nowrap;position:relative;cursor:pointer} @@ -180,7 +180,7 @@ html.gecko.mac .builder-controllers-builder-area ul.controller-behavior-list{pad .builder-tabs>.tabs>ul.tabs>li:hover>div{color:#95a5a6 !important} .builder-tabs>.tabs>ul.tabs>li .tab-control{display:none} .builder-tabs>.tabs>ul.tabs>li .tab-control.close-btn{font-size:15px;top:7px;right:18px;line-height:15px;height:15px;width:15px;text-align:center;cursor:pointer;color:#95a5a6} -.builder-tabs>.tabs>ul.tabs>li .tab-control.close-btn:hover{color:#0181b9 !important} +.builder-tabs>.tabs>ul.tabs>li .tab-control.close-btn:hover{color:#2da7c7 !important} .builder-tabs>.tabs>ul.tabs>li .tab-control.inspector-trigger{right:34px;top:10px} .builder-tabs>.tabs>ul.tabs>li.active>div.tab-container{color:#95a5a6 !important} .builder-tabs>.tabs>ul.tabs>li.active .tab-control{display:block} @@ -222,9 +222,9 @@ html.gecko .builder-tabs.primary>.tabs>ul.tabs>li>div.tab-container>div{padding- .builder-menu-editor ul.builder-menu{font-size:0;padding:0;cursor:pointer} .builder-menu-editor ul.builder-menu>li{-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px} .builder-menu-editor ul.builder-menu>li div.item-container:hover, -.builder-menu-editor ul.builder-menu>li.inspector-open>div.item-container{background:#2581b8 !important;color:white !important} +.builder-menu-editor ul.builder-menu>li.inspector-open>div.item-container{background:#2581b8 !important;color:white!important} .builder-menu-editor ul.builder-menu>li div.item-container:hover a, -.builder-menu-editor ul.builder-menu>li.inspector-open>div.item-container a{color:white !important} +.builder-menu-editor ul.builder-menu>li.inspector-open>div.item-container a{color:white!important} .builder-menu-editor ul.builder-menu>li div.item-container{position:relative} .builder-menu-editor ul.builder-menu>li div.item-container .close-btn{color:white;position:absolute;display:none;width:15px;height:15px;right:5px;top:5px;font-size:14px;text-align:center;line-height:14px} .builder-menu-editor ul.builder-menu>li div.item-container:hover .close-btn{display:block;text-decoration:none;opacity:0.5;filter:alpha(opacity=50)} @@ -253,22 +253,24 @@ html.gecko .builder-tabs.primary>.tabs>ul.tabs>li>div.tab-container>div{padding- .builder-menu-editor ul.builder-menu.builder-submenu>li>div.item-container i{font-size:24px} .builder-menu-editor ul.builder-menu.builder-submenu>li.add{margin-top:20px} .builder-menu-editor ul.builder-menu.builder-submenu>li.add a{padding:10px 20px;display:block} -.localization-input-container input[type=text].string-editor{padding-right:20px !important} +.localization-input-container input[type=text].string-editor{padding-right:20px!important} .localization-input-container .localization-trigger{position:absolute;display:none;width:10px;height:10px;font-size:14px;color:#95a5a6;outline:none} .localization-input-container .localization-trigger:hover, .localization-input-container .localization-trigger:active, .localization-input-container .localization-trigger:focus{color:#2581b8;text-decoration:none} table.inspector-fields td.active .localization-input-container .localization-trigger, table.data td.active .localization-input-container .localization-trigger{display:block} -table.data td.active .localization-input-container .localization-trigger{top:5px !important;right:7px !important} -.control-table td[data-column-type=builderLocalization] input[type=text]{padding-right:20px !important} +table.data td.active .localization-input-container .localization-trigger{top:5px!important;right:7px!important} +.control-table td[data-column-type=builderLocalization] input[type=text]{padding-right:20px!important} .control-table td[data-column-type=builderLocalization] input[type=text]{width:100%;height:100%;display:block;outline:none;border:none;padding:6px 10px 6px} html.chrome .control-table td[data-column-type=builderLocalization] input[type=text]{padding:6px 10px 7px!important} html.safari .control-table td[data-column-type=builderLocalization] input[type=text], html.gecko .control-table td[data-column-type=builderLocalization] input[type=text]{padding:5px 10px 5px} .autocomplete.dropdown-menu.table-widget-autocomplete.localization li a{white-space:normal;word-wrap:break-word} -table.data td[data-column-type=builderLocalization] .loading-indicator-container.size-small .loading-indicator{padding-bottom:0 !important} +table.data td[data-column-type=builderLocalization] .loading-indicator-container.size-small .loading-indicator{padding-bottom:0!important} table.data td[data-column-type=builderLocalization] .loading-indicator-container.size-small .loading-indicator span{left:auto;right:6px} +.builder-new-translation-line{background-color:rgba(16,185,129,0.12) !important} +.builder-new-translation-gutter{background-color:rgba(16,185,129,0.6) !important;width:3px !important;margin-left:3px !important} .control-filelist ul li.group.model>h4 a:after{content:"\f074";top:10px} .control-filelist ul li.group.model>.controls{display:none !important;right:29px} .control-filelist ul li.group.model h4:hover + .controls, @@ -283,7 +285,7 @@ table.data td[data-column-type=builderLocalization] .loading-indicator-container .control-filelist ul li.with-icon i.list-icon.icon-check-square{color:#8da85e} html.gecko .control-filelist ul li.group{margin-right:10px} .builder-inspector-container{width:350px;border-left:1px solid #d9d9d9} -.builder-inspector-container:empty{display:none !important} +.builder-inspector-container:empty{display:none!important} form.hide-secondary-tabs div.control-tabs.secondary-tabs ul.nav.nav-tabs{display:none} .form-group.size-quarter{width:23.5%} .form-group.size-three-quarter{width:73.5%} @@ -294,7 +296,7 @@ form[data-entity=permissions] div.field-datatable div[data-control=table]{positi form[data-entity=database] div.field-datatable div[data-control=table] div.table-container, form[data-entity=permissions] div.field-datatable div[data-control=table] div.table-container{position:absolute;width:100%;height:100%} form[data-entity=database] div.field-datatable div[data-control=table] div.table-container div.control-scrollbar, -form[data-entity=permissions] div.field-datatable div[data-control=table] div.table-container div.control-scrollbar{top:72px;bottom:0;position:absolute;max-height:none !important;height:auto !important} +form[data-entity=permissions] div.field-datatable div[data-control=table] div.table-container div.control-scrollbar{top:72px;bottom:0;position:absolute;max-height:none!important;height:auto!important} div.control-table .toolbar a.builder-custom-table-button:before{line-height:17px;font-size:21px;color:#323e50;margin-right:5px;top:3px;opacity:1;filter:alpha(opacity=100)} .control-tabs.auxiliary-tabs{background:white} .control-tabs.auxiliary-tabs>ul.nav-tabs, @@ -304,13 +306,13 @@ div.control-table .toolbar a.builder-custom-table-button:before{line-height:17px .control-tabs.auxiliary-tabs>ul.nav-tabs>li, .control-tabs.auxiliary-tabs>div>ul.nav-tabs>li{margin-right:2px} .control-tabs.auxiliary-tabs>ul.nav-tabs>li>a, -.control-tabs.auxiliary-tabs>div>ul.nav-tabs>li>a{background:white;color:#bdc3c7;border-left:1px solid #ecf0f1!important;border-right:1px solid #ecf0f1!important;border-bottom:1px solid #ecf0f1!important;padding:4px 10px;line-height:100%;-webkit-border-radius:0 0 4px 4px;-moz-border-radius:0 0 4px 4px;border-radius:0 0 4px 4px} +.control-tabs.auxiliary-tabs>div>ul.nav-tabs>li>a{background:white;color:#bdc3c7;border-left:1px solid #ecf0f1 !important;border-right:1px solid #ecf0f1 !important;border-bottom:1px solid #ecf0f1 !important;padding:4px 10px;line-height:100%;-webkit-border-radius:0 0 4px 4px;-moz-border-radius:0 0 4px 4px;border-radius:0 0 4px 4px} .control-tabs.auxiliary-tabs>ul.nav-tabs>li>a>span.title>span, .control-tabs.auxiliary-tabs>div>ul.nav-tabs>li>a>span.title>span{margin-bottom:0;font-size:13px;height:auto} .control-tabs.auxiliary-tabs>ul.nav-tabs>li.active, .control-tabs.auxiliary-tabs>div>ul.nav-tabs>li.active{top:0} .control-tabs.auxiliary-tabs>ul.nav-tabs>li.active:before, -.control-tabs.auxiliary-tabs>div>ul.nav-tabs>li.active:before{content:' ';display:block;position:absolute;width:100%;height:1px;background:#fff;top:0;left:0;top:-1px} +.control-tabs.auxiliary-tabs>div>ul.nav-tabs>li.active:before{content:' ';display:block;position:absolute;width:100%;height:1px;background:white;top:0;left:0;top:-1px} .control-tabs.auxiliary-tabs>ul.nav-tabs>li.active a, -.control-tabs.auxiliary-tabs>div>ul.nav-tabs>li.active a{padding-top:5px;border-left:1px solid #95a5a6!important;border-right:1px solid #95a5a6!important;border-bottom:1px solid #95a5a6!important;color:#95a5a6} +.control-tabs.auxiliary-tabs>div>ul.nav-tabs>li.active a{padding-top:5px;border-left:1px solid #95a5a6 !important;border-right:1px solid #95a5a6 !important;border-bottom:1px solid #95a5a6 !important;color:#95a5a6} .control-tabs.auxiliary-tabs>div.tab-content>.tab-pane{background:white} \ No newline at end of file diff --git a/assets/js/build-min.js b/assets/js/build-min.js index f4e2065..c9ea56e 100644 --- a/assets/js/build-min.js +++ b/assets/js/build-min.js @@ -425,32 +425,32 @@ if(data.builderResponseData.registryData!==undefined){var registryData=data.buil $.wn.builder.dataRegistry.set(registryData.pluginCode,'localization',null,registryData.strings,{suppressLanguageEditorUpdate:true}) $.wn.builder.dataRegistry.set(registryData.pluginCode,'localization','sections',registryData.sections)}} Localization.prototype.getLanguageList=function(){return $('#layout-side-panel form[data-content-id=localization] [data-control=filelist]')} -Localization.prototype.getCodeEditor=function($tab){return $tab.find('div[data-field-name=strings] div[data-control=codeeditor]').data('oc.codeEditor').editor} +Localization.prototype.getCodeEditor=function($tab){return $tab.find('div[data-field-name=strings] div[data-control=codeeditor]').data('oc.codeEditor')} Localization.prototype.deleteConfirmed=function(){var $masterTabPane=this.getMasterTabsActivePane(),$form=$masterTabPane.find('form') $.wn.stripeLoadIndicator.show() $form.request('onLanguageDelete').always($.wn.builder.indexController.hideStripeIndicatorProxy).done(this.proxy(this.deleteDone))} Localization.prototype.deleteDone=function(){var $masterTabPane=this.getMasterTabsActivePane() this.getIndexController().unchangeTab($masterTabPane) this.forceCloseTab($masterTabPane)} -Localization.prototype.copyStringsFromDone=function(data){if(data['builderResponseData']===undefined){throw new Error('Invalid response data')}var responseData=data.builderResponseData,$masterTabPane=this.getMasterTabsActivePane(),$form=$masterTabPane.find('form'),codeEditor=this.getCodeEditor($masterTabPane),newStringMessage=$form.data('newStringMessage'),mismatchMessage=$form.data('structureMismatch') -codeEditor.getSession().setValue(responseData.strings) -var annotations=[] +Localization.prototype.copyStringsFromDone=function(data){if(data['builderResponseData']===undefined){throw new Error('Invalid response data')}var responseData=data.builderResponseData,$masterTabPane=this.getMasterTabsActivePane(),$form=$masterTabPane.find('form'),codeEditorWrapper=this.getCodeEditor($masterTabPane),newStringMessage=$form.data('newStringMessage'),mismatchMessage=$form.data('structureMismatch') +codeEditorWrapper.setValue(responseData.strings) +var decorations=[] for(var i=responseData.updatedLines.length-1;i>=0;i--){var line=responseData.updatedLines[i] -annotations.push({row:line,column:0,text:newStringMessage,type:'warning'})}codeEditor.getSession().setAnnotations(annotations) +decorations.push({range:new codeEditorWrapper.monaco.Range(line+1,1,line+1,Number.MAX_VALUE),options:{isWholeLine:true,className:'builder-new-translation-line',linesDecorationsClassName:'builder-new-translation-gutter',hoverMessage:{value:newStringMessage}}})}codeEditorWrapper.setDecorations('builderLocalization',decorations) if(responseData.mismatch){$.wn.alert(mismatchMessage)}} Localization.prototype.findDefaultLanguageForm=function(plugin){var forms=document.body.querySelectorAll('form[data-entity=localization]') for(var i=forms.length-1;i>=0;i--){var form=forms[i],pluginInput=form.querySelector('input[name=plugin_code]'),languageInput=form.querySelector('input[name=original_language]') if(!pluginInput||pluginInput.value!=plugin){continue}if(!languageInput){continue}if(form.getAttribute('data-default-language')==languageInput.value){return form}}return null} Localization.prototype.updateLanguageFromServer=function($languageForm){var self=this $languageForm.request('onLanguageGetStrings').done(function(data){self.updateLanguageFromServerDone($languageForm,data)})} -Localization.prototype.updateLanguageFromServerDone=function($languageForm,data){if(data['builderResponseData']===undefined){throw new Error('Invalid response data')}var responseData=data.builderResponseData,$tabPane=$languageForm.closest('.tab-pane'),codeEditor=this.getCodeEditor($tabPane) -if(!responseData.strings){return}codeEditor.getSession().setValue(responseData.strings) +Localization.prototype.updateLanguageFromServerDone=function($languageForm,data){if(data['builderResponseData']===undefined){throw new Error('Invalid response data')}var responseData=data.builderResponseData,$tabPane=$languageForm.closest('.tab-pane'),codeEditorWrapper=this.getCodeEditor($tabPane) +if(!responseData.strings){return}codeEditorWrapper.setValue(responseData.strings) this.unmodifyTab($tabPane)} Localization.prototype.mergeLanguageFromServer=function($languageForm){var language=$languageForm.find('input[name=original_language]').val(),self=this $languageForm.request('onLanguageCopyStringsFrom',{data:{copy_from:language}}).done(function(data){self.mergeLanguageFromServerDone($languageForm,data)})} -Localization.prototype.mergeLanguageFromServerDone=function($languageForm,data){if(data['builderResponseData']===undefined){throw new Error('Invalid response data')}var responseData=data.builderResponseData,$tabPane=$languageForm.closest('.tab-pane'),codeEditor=this.getCodeEditor($tabPane) -codeEditor.getSession().setValue(responseData.strings) -codeEditor.getSession().setAnnotations([])} +Localization.prototype.mergeLanguageFromServerDone=function($languageForm,data){if(data['builderResponseData']===undefined){throw new Error('Invalid response data')}var responseData=data.builderResponseData,$tabPane=$languageForm.closest('.tab-pane'),codeEditorWrapper=this.getCodeEditor($tabPane) +codeEditorWrapper.setValue(responseData.strings) +codeEditorWrapper.setDecorations('builderLocalization',[])} $.wn.builder.entityControllers.localization=Localization;}(window.jQuery);+function($){"use strict";if($.wn.builder===undefined)$.wn.builder={} if($.wn.builder.entityControllers===undefined)$.wn.builder.entityControllers={} var Base=$.wn.builder.entityControllers.base,BaseProto=Base.prototype diff --git a/assets/js/builder.index.entity.localization.js b/assets/js/builder.index.entity.localization.js index c831fc7..02e6996 100644 --- a/assets/js/builder.index.entity.localization.js +++ b/assets/js/builder.index.entity.localization.js @@ -144,7 +144,9 @@ } Localization.prototype.getCodeEditor = function($tab) { - return $tab.find('div[data-field-name=strings] div[data-control=codeeditor]').data('oc.codeEditor').editor + // Returns the Monaco wrapper (not the raw editor) to use wrapper methods + // The wrapper provides setValue(), getValue(), insert(), etc. + return $tab.find('div[data-field-name=strings] div[data-control=codeeditor]').data('oc.codeEditor') } Localization.prototype.deleteConfirmed = function() { @@ -174,25 +176,38 @@ var responseData = data.builderResponseData, $masterTabPane = this.getMasterTabsActivePane(), $form = $masterTabPane.find('form'), - codeEditor = this.getCodeEditor($masterTabPane), + codeEditorWrapper = this.getCodeEditor($masterTabPane), newStringMessage = $form.data('newStringMessage'), mismatchMessage = $form.data('structureMismatch') - codeEditor.getSession().setValue(responseData.strings) + // Use Monaco wrapper API instead of ACE's getSession() + codeEditorWrapper.setValue(responseData.strings) - var annotations = [] + // Convert ACE annotations to Monaco decorations (visual highlights) + // ACE uses 0-indexed rows, Monaco uses 1-indexed lines + // Using decorations instead of markers to avoid error-like squiggly underlines + var decorations = [] for (var i=responseData.updatedLines.length-1; i>=0; i--) { var line = responseData.updatedLines[i] - annotations.push({ - row: line, - column: 0, - text: newStringMessage, - type: 'warning' + decorations.push({ + range: new codeEditorWrapper.monaco.Range( + line + 1, // Convert 0-indexed to 1-indexed + 1, // Start column + line + 1, // End line (same line) + Number.MAX_VALUE // End column (end of line) + ), + options: { + isWholeLine: true, + className: 'builder-new-translation-line', // Background highlight + linesDecorationsClassName: 'builder-new-translation-gutter', // Gutter indicator + hoverMessage: { value: newStringMessage } // Tooltip on hover + } }) } - codeEditor.getSession().setAnnotations(annotations) + // Set decorations using wrapper method + codeEditorWrapper.setDecorations('builderLocalization', decorations) if (responseData.mismatch) { $.wn.alert(mismatchMessage) @@ -238,13 +253,14 @@ var responseData = data.builderResponseData, $tabPane = $languageForm.closest('.tab-pane'), - codeEditor = this.getCodeEditor($tabPane) + codeEditorWrapper = this.getCodeEditor($tabPane) if (!responseData.strings) { return } - codeEditor.getSession().setValue(responseData.strings) + // Use Monaco wrapper API + codeEditorWrapper.setValue(responseData.strings) this.unmodifyTab($tabPane) } @@ -268,10 +284,12 @@ var responseData = data.builderResponseData, $tabPane = $languageForm.closest('.tab-pane'), - codeEditor = this.getCodeEditor($tabPane) + codeEditorWrapper = this.getCodeEditor($tabPane) - codeEditor.getSession().setValue(responseData.strings) - codeEditor.getSession().setAnnotations([]) + // Use Monaco wrapper API + codeEditorWrapper.setValue(responseData.strings) + // Clear any decorations + codeEditorWrapper.setDecorations('builderLocalization', []) } // REGISTRATION diff --git a/assets/less/localization.less b/assets/less/localization.less index a8637ff..0b9a401 100644 --- a/assets/less/localization.less +++ b/assets/less/localization.less @@ -83,4 +83,17 @@ table.data td[data-column-type=builderLocalization] .loading-indicator-container left: auto; right: 6px; } +} + +// Monaco CodeEditor decorations for new translation strings +// Using decorations instead of markers to avoid error-like squiggly underlines +// Soft green highlight similar to GitHub's diff view for added content +.builder-new-translation-line { + background-color: rgba(16, 185, 129, 0.12) !important; // Soft green background +} + +.builder-new-translation-gutter { + background-color: rgba(16, 185, 129, 0.6) !important; // Green gutter indicator + width: 3px !important; + margin-left: 3px !important; } \ No newline at end of file From 88a82370a4752d9a55f041a814d1e524e7a49213 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Thu, 1 Jan 2026 10:20:07 -0600 Subject: [PATCH 3/3] Add ability for dynamic form widgets to reconstruct themselves JIT --- behaviors/IndexDatabaseTableOperations.php | 42 +++++++++++ behaviors/IndexLocalizationOperations.php | 58 +++++++++++++++ behaviors/IndexVersionsOperations.php | 60 +++++++++++++++ classes/IndexOperationsBehaviorBase.php | 86 ++++++++++++++++++++++ 4 files changed, 246 insertions(+) diff --git a/behaviors/IndexDatabaseTableOperations.php b/behaviors/IndexDatabaseTableOperations.php index c1eef49..1650768 100644 --- a/behaviors/IndexDatabaseTableOperations.php +++ b/behaviors/IndexDatabaseTableOperations.php @@ -206,6 +206,48 @@ protected function makeMigrationFormWidget($migration, $alias = null) return $form; } + /** + * Reconstructs a migration FormWidget from POST data + * + * This allows widgets within popups (like CodeEditor) to make AJAX requests + * by rebuilding the FormWidget state from the request data + * + * @param string $alias The exact widget alias to recreate + * @return \Backend\Widgets\Form|null The reconstructed form widget, or null if not a migration form + */ + protected function reconstructFormWidget($alias) + { + // Only handle migration forms (popup forms for database table operations) + if (!preg_match('/^form_migration_[a-z0-9]+_$/i', $alias)) { + return parent::reconstructFormWidget($alias); + } + + // Get plugin code from request or fall back to active plugin + $pluginCode = Request::input('plugin_code'); + if (!$pluginCode) { + $pluginCode = $this->getPluginCode()->toCode(); + } + + // Build MigrationModel from POST data + $migration = new MigrationModel(); + $migration->setPluginCodeObj(new PluginCode($pluginCode)); + $migration->fill([ + 'version' => Request::input('version', ''), + 'description' => Request::input('description', ''), + 'code' => Request::input('code', '') + ]); + + // Create FormWidget with exact same config as original popup + // Using the EXACT alias from the AJAX handler is critical + $form = $this->makeMigrationFormWidget($migration, $alias); + + // CRITICAL: Bind to controller so it's available in $this->widget + // This makes the widget discoverable when the AJAX handler looks it up + $form->bindToController(); + + return $form; + } + protected function processColumnData($postData) { if (!array_key_exists('columns', $postData)) { diff --git a/behaviors/IndexLocalizationOperations.php b/behaviors/IndexLocalizationOperations.php index ab8daa8..e78f727 100644 --- a/behaviors/IndexLocalizationOperations.php +++ b/behaviors/IndexLocalizationOperations.php @@ -215,4 +215,62 @@ protected function loadOrCreateBaseModel($language, $options = []) $model->load($language); return $model; } + + /** + * Reconstructs a localization FormWidget from POST data + * + * This allows widgets within localization tabs (like CodeEditor) to make AJAX requests + * by rebuilding the FormWidget state from the request data + * + * @param string $alias The exact widget alias to recreate + * @return \Backend\Widgets\Form|null The reconstructed form widget + */ + protected function reconstructFormWidget($alias) + { + // Only handle localization tab forms (pattern: form_{md5}{uniqid}) + // Not migration popup forms (pattern: form_migration_{uniqid}_) + if (preg_match('/^form_migration_[a-z0-9]+_$/i', $alias)) { + return parent::reconstructFormWidget($alias); + } + + // Check if this alias belongs to this behavior + $expectedPrefix = 'form_' . md5(get_class($this)); + if (strpos($alias, $expectedPrefix) !== 0) { + return parent::reconstructFormWidget($alias); + } + + // Get plugin code from request or fall back to active plugin + $pluginCode = Request::input('plugin_code'); + if (!$pluginCode) { + $pluginCode = $this->getPluginCode()->toCode(); + } + + // Get language from request + $language = Input::get('original_language'); + + // Build LocalizationModel from POST data + $options = ['pluginCode' => $pluginCode]; + $model = $this->loadOrCreateBaseModel($language, $options); + + // Fill with current form data + $model->fill([ + 'language' => Request::input('language', ''), + 'strings' => Request::input('strings', '') + ]); + + // Create FormWidget with exact same config as original tab + // Using the EXACT alias from the AJAX handler is critical + $widgetConfig = $this->makeConfig($this->baseFormConfigFile); + $widgetConfig->model = $model; + $widgetConfig->alias = $alias; + + $form = $this->makeWidget('Backend\\Widgets\\Form', $widgetConfig); + $form->context = strlen($language) ? 'update' : 'create'; + + // CRITICAL: Bind to controller so it's available in $this->widget + // This makes the widget discoverable when the AJAX handler looks it up + $form->bindToController(); + + return $form; + } } diff --git a/behaviors/IndexVersionsOperations.php b/behaviors/IndexVersionsOperations.php index c6890b6..0fe2666 100644 --- a/behaviors/IndexVersionsOperations.php +++ b/behaviors/IndexVersionsOperations.php @@ -175,4 +175,64 @@ protected function loadOrCreateBaseModel($versionNumber, $options = []) $model->load($versionNumber); return $model; } + + /** + * Reconstructs a version FormWidget from POST data + * + * This allows widgets within version tabs (like CodeEditor) to make AJAX requests + * by rebuilding the FormWidget state from the request data + * + * @param string $alias The exact widget alias to recreate + * @return \Backend\Widgets\Form|null The reconstructed form widget + */ + protected function reconstructFormWidget($alias) + { + // Only handle version tab forms (pattern: form_{md5}{uniqid}) + // Not migration popup forms (pattern: form_migration_{uniqid}_) + if (preg_match('/^form_migration_[a-z0-9]+_$/i', $alias)) { + return parent::reconstructFormWidget($alias); + } + + // Check if this alias belongs to this behavior + $expectedPrefix = 'form_' . md5(get_class($this)); + if (strpos($alias, $expectedPrefix) !== 0) { + return parent::reconstructFormWidget($alias); + } + + // Get plugin code from request or fall back to active plugin + $pluginCode = Request::input('plugin_code'); + if (!$pluginCode) { + $pluginCode = $this->getPluginCode()->toCode(); + } + + // Get version number from request + $versionNumber = Input::get('original_version'); + + // Build MigrationModel from POST data + $options = ['pluginCode' => $pluginCode]; + $model = $this->loadOrCreateBaseModel($versionNumber, $options); + + // Fill with current form data + $model->fill([ + 'version' => Request::input('version', ''), + 'description' => Request::input('description', ''), + 'code' => Request::input('code', ''), + 'scriptFileName' => Request::input('scriptFileName', '') + ]); + + // Create FormWidget with exact same config as original tab + // Using the EXACT alias from the AJAX handler is critical + $widgetConfig = $this->makeConfig($this->baseFormConfigFile); + $widgetConfig->model = $model; + $widgetConfig->alias = $alias; + + $form = $this->makeWidget('Backend\\Widgets\\Form', $widgetConfig); + $form->context = strlen($versionNumber) ? 'update' : 'create'; + + // CRITICAL: Bind to controller so it's available in $this->widget + // This makes the widget discoverable when the AJAX handler looks it up + $form->bindToController(); + + return $form; + } } diff --git a/classes/IndexOperationsBehaviorBase.php b/classes/IndexOperationsBehaviorBase.php index cfa0c85..6e0224b 100644 --- a/classes/IndexOperationsBehaviorBase.php +++ b/classes/IndexOperationsBehaviorBase.php @@ -3,6 +3,8 @@ use Backend\Classes\ControllerBehavior; use Backend\Behaviors\FormController; use ApplicationException; +use Request; +use Exception; /** * Base class for index operation behaviors @@ -14,6 +16,20 @@ abstract class IndexOperationsBehaviorBase extends ControllerBehavior { protected $baseFormConfigFile = null; + /** + * Constructor - Set up AJAX event listener for FormWidget reconstruction + */ + public function __construct($controller) + { + parent::__construct($controller); + + // Reconstruct FormWidgets for AJAX requests from CodeEditor and other widgets + // This allows widgets within forms to make AJAX calls by rebuilding FormWidget state + $controller->bindEvent('ajax.beforeRunHandler', function ($handler) { + return $this->handleFormWidgetAjax($handler); + }); + } + protected function makeBaseFormWidget($modelCode, $options = [], $aliasSuffix = null) { if (!strlen($this->baseFormConfigFile)) { @@ -42,5 +58,75 @@ protected function getPluginCode() return $vector->pluginCodeObj; } + /** + * Handle AJAX requests from form widgets (like CodeEditor) + * Reconstructs the FormWidget if it doesn't exist + * + * @param string $handler The AJAX handler name (e.g., "form_xxx::onLoadTheme") + * @return mixed Null to continue, or response to override + */ + protected function handleFormWidgetAjax($handler) + { + // Only process widget-scoped handlers (format: alias::method) + if (!strpos($handler, '::')) { + return null; + } + + list($widgetAlias, $handlerName) = explode('::', $handler, 2); + + // Extract the form widget alias from nested widget aliases + // Nested widgets have pattern: {formAlias}{FieldName} + // Example: form_71c55b7edc094f5b8236ee9af75fe35d695591ef6aa4eCode + // where form_71c55b7edc094f5b8236ee9af75fe35d695591ef6aa4e is form alias, Code is field name + + // Match any form widget alias pattern used by Builder behaviors: + // 1. form_migration_{uniqid}_ (used by popups in IndexDatabaseTableOperations) + // 2. form_{md5hash}{uniqid} (used by base class for tabs) + if (!preg_match('/^(form_[a-z0-9_]+)[A-Z]/', $widgetAlias, $matches)) { + return null; + } + + $formAlias = $matches[1]; + + // Skip if form widget already exists (normal flow worked) + if (isset($this->controller->widget->{$formAlias})) { + return null; + } + + // Reconstruct the FormWidget from request data + try { + $this->reconstructFormWidget($formAlias); + + if (config('app.debug')) { + trace_log("Reconstructed FormWidget for nested widget AJAX", [ + 'behavior' => get_class($this), + 'nested_widget_alias' => $widgetAlias, + 'form_alias' => $formAlias, + 'handler' => Request::header('X_WINTER_REQUEST_HANDLER'), + 'method' => $handlerName, + ]); + } + } catch (Exception $ex) { + // Log the error but don't break - let normal flow handle the missing widget error + trace_log("Failed to reconstruct form widget: " . $ex->getMessage()); + } + + // Return null to continue with normal handler execution + return null; + } + + /** + * Reconstructs a FormWidget from POST data + * Child classes should override this to provide behavior-specific reconstruction + * + * @param string $alias The exact widget alias to recreate + * @return \Backend\Widgets\Form|null The reconstructed form widget, or null if not supported + */ + protected function reconstructFormWidget($alias) + { + // Default implementation - child classes can override + return null; + } + abstract protected function loadOrCreateBaseModel($modelCode, $options = []); }