diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3085aca..a7bde38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: - name: Upload Test Results Artifacts if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.cfengine }} path: | @@ -116,7 +116,7 @@ jobs: - name: Upload Debugging Info To Artifacts if: ${{ failure() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: Failure Debugging Info - ${{ matrix.cfengine }} path: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8db6ad3..8bd386d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -83,7 +83,7 @@ jobs: - name: Upload Debugging Info To Artifacts if: ${{ failure() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: Failure Debugging Info - ${{ matrix.cfengine }} path: | diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index e2c878c..b9e2a69 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -34,16 +34,7 @@ component { * The default folder name where your cbwire components are stored. * Defaults to 'wires' folder. */ - "componentLocation" : "wires", - /** - * Determines if Turbo should be enabled - */ - "enableTurbo" : false, - /** - * Caching for single-file components to speed up response time. - * Should be false for local development. - */ - "cacheSingleFileComponents": false, + "wiresLocation" : "wires", /** * Trims string properties if set to true */ diff --git a/handlers/Main.cfc b/handlers/Main.cfc index 0a79f34..c605179 100644 --- a/handlers/Main.cfc +++ b/handlers/Main.cfc @@ -8,7 +8,17 @@ component { * URI: /cbwire/update */ function index( event, rc, prc ){ - return cbwireController.handleRequest( getHTTPRequestData(), arguments.event ); + try { + return cbwireController.handleRequest( getHTTPRequestData(), arguments.event ); + } catch ( any e ) { + if ( e.message contains "Page expired" ) { + event.noLayout(); + event.setView( view="errors/pageExpired", module="cbwire" ); + event.setHTTPHeader( statusCode="419", statusText="Page Expired" ); + } else { + rethrow; + } + } } /** diff --git a/interceptors/CBWIRE.cfc b/interceptors/CBWIRE.cfc index edc29ee..5f5011e 100644 --- a/interceptors/CBWIRE.cfc +++ b/interceptors/CBWIRE.cfc @@ -1,14 +1,21 @@ component { /** - * Ensures that no other custom interceptors run for the cbwire module. - * Returns true to break the interceptor chain. - * - * @return boolean + * Returns the module settings. + * + * @return struct */ - function afterAspectsLoad() { - variables.CBWIREController = getInstance( dsl="CBWIREController@cbwire"); - variables.settings = getInstance( dsl="coldbox:modulesettings:cbwire" ); + function getSettings() { + return getInstance( "coldbox:modulesettings:cbwire" ); + } + + /** + * Returns the CBWIRE controller. + * + * @return CBWIREController + */ + function getCBWIREController() { + return getInstance( "CBWIREController@cbwire" ); } /** @@ -17,7 +24,8 @@ component { * @return void */ function preReinit() { - local.tmpDirectory = variables.settings.moduleRootPath & "/models/tmp"; + local.settings = getSettings(); + local.tmpDirectory = local.settings.moduleRootPath & "/models/tmp"; if ( directoryExists( local.tmpDirectory ) ) { directoryDelete( local.tmpDirectory, true ); @@ -69,9 +77,8 @@ component { data = "", statusCode = 400 ).noExecution(); - // Returning true breaks further interceptors execution. - return true; } + return true; } function preEvent() eventPattern="^cbwire.*" { @@ -123,9 +130,10 @@ component { } function postLayoutRender() { - if ( shouldInject( arguments.event ) ) { + if ( shouldInject( arguments.event ) && !request.keyExists( "_cbwire_injected_assets" ) ) { arguments.data.renderedLayout = replaceNoCase( arguments.data.renderedLayout, "", getStyles() & chr( 10 ) & "", "one" ); arguments.data.renderedLayout = replaceNoCase( arguments.data.renderedLayout, "", getScripts() & chr( 10 ) & "", "one" ); + request._cbwire_injected_assets = true; } } @@ -172,7 +180,7 @@ component { * @return string */ private function getStyles() { - return variables.CBWIREController.getStyles(); + return getCBWIREController().getStyles(); } /** @@ -181,7 +189,7 @@ component { * @return string */ private function getScripts() { - return variables.CBWIREController.getScripts(); + return getCBWIREController().getScripts(); } /** @@ -192,6 +200,7 @@ component { * @return boolean */ private function shouldInject( event ) { - return arguments.event.getCurrentModule() != "cbwire" && variables.settings.autoInjectAssets == true; + local.settings = getSettings(); + return arguments.event.getCurrentModule() != "cbwire" && local.settings.autoInjectAssets == true; } } \ No newline at end of file diff --git a/models/CBWIREController.cfc b/models/CBWIREController.cfc index cc91aa2..9bca55c 100644 --- a/models/CBWIREController.cfc +++ b/models/CBWIREController.cfc @@ -12,12 +12,17 @@ component singleton { // Inject module settings property name="moduleSettings" inject="coldbox:modulesettings:cbwire"; - // Inject module service - property name="moduleService" inject="coldbox:moduleService"; + // Inject module service + property name="moduleService" inject="coldbox:moduleService"; // Inject SingleFileComponentBuilder property name="singleFileComponentBuilder" inject="SingleFileComponentBuilder@cbwire"; + function init() { + // Initialize the array to store single file components + variables._singleFileComponents = []; + return this; + } /** * Instantiates a CBWIRE component, mounts it, * and then calls its internal renderIt() method. @@ -32,6 +37,7 @@ component singleton { */ function wire(required name, params = {}, key = "", lazy = false, lazyIsolated = true ) { local.instance = createInstance(argumentCollection=arguments) + ._withPath( arguments.name ) ._withEvent( getEvent() ) ._withParams( arguments.params, arguments.lazy ) ._withKey( arguments.key ); @@ -59,8 +65,7 @@ component singleton { local.csrfTokenVerified = variables.wirebox.getInstance( dsl="@cbcsrf" ).verify( local.csrfToken ); // Check the CSRF token, throw 403 if invalid if( !local.csrfTokenVerified ){ - cfheader( statusCode="403", statusText="Forbidden" ); - throw( type="CBWIREException", message="Invalid CSRF token." ); + throw( type="CBWIREException", message="Page expired." ); } // Perform additional deserialization of the component snapshots local.payload.components = local.payload.components.map( function( _comp ) { @@ -74,6 +79,7 @@ component singleton { local.componentInstance = createInstance( _componentPayload.snapshot.memo.name ); // Return the response for this component return local.componentInstance + ._withPath( _componentPayload.snapshot.memo.name ) ._withEvent( event ) ._withIncomingPayload( _componentPayload ) ._getHTTPResponse( _componentPayload ); @@ -173,19 +179,26 @@ component singleton { local.fullComponentPath = "wires." & local.fullComponentPath; } - if ( find( "@", local.fullComponentPath ) ) { - // This is a module reference, find in our module - var params = listToArray( local.fullComponentPath, "@" ); - if ( params.len() != 2 ) { - throw( type="ModuleNotFound", message = "CBWIRE cannot locate the module or component using '" & local.fullComponentPath & "'." ); - } + if ( find( "@", local.fullComponentPath ) ) { + // This is a module reference, find in our module + var params = listToArray( local.fullComponentPath, "@" ); + if ( params.len() != 2 ) { + throw( type="ModuleNotFound", message = "CBWIRE cannot locate the module or component using '" & local.fullComponentPath & "'." ); + } // modify local.fullComponentPath to full path for module local.fullComponentPath = getModuleComponentPath( params[ 1 ], params[ 2 ] ); - } + } try { + // Check if we've already flagged this component as a single file component + // This is to improve performance by not attempting to create the component again + if ( variables._singleFileComponents.contains( arguments.name ) ) { + throw( type="Injector.InstanceNotFoundException", message="Component '#arguments.name#' is a single file component." ); + } // Attempt to create an instance of the component - return variables.wirebox.getInstance(local.fullComponentPath); + local.componentInstance = variables.wirebox.getInstance(local.fullComponentPath) + ._withPath( arguments.name ); + return local.componentInstance; } catch( Injector.InstanceNotFoundException e ) { local.singleFileComponent = variables.singleFileComponentBuilder .setInitialRender( true ) @@ -196,6 +209,7 @@ component singleton { abort; rethrow; } + variables._singleFileComponents.append( arguments.name ); return local.singleFileComponent; } catch (Any e) { @@ -206,24 +220,56 @@ component singleton { } } - /** - * Returns the full dot notation path to a modules component. - * - * @path String | Name of the cbwire component. - * @module String | Name of the module to look for wire in. - */ - private function getModuleComponentPath( path, module ) { - var moduleConfig = moduleService.getModuleConfigCache(); - if ( !moduleConfig.keyExists( module ) ) { - throw( type="ModuleNotFound", message = "CBWIRE cannot locate the module '" & arguments.module & "'.") - } - - // if there is a dot in the path, then we are referencing a folder within a module otherwise use the default wire location. - var moduleRegistry = moduleService.getModuleRegistry(); - return arguments.path contains "." ? - moduleRegistry[ module ].invocationPath & "." & module & "." & arguments.path : - moduleRegistry[ module ].invocationPath & "." & module & "." & getWiresLocation() & "." & arguments.path; - } + /** + * Returns the path to the modules folder. + * + * @module string | The name of the module. + * + * @return string + */ + function getModuleRootPath( module ) { + var moduleRegistry = moduleService.getModuleRegistry(); + + if ( moduleRegistry.keyExists( module ) ) { + return moduleRegistry[ module ].invocationPath & "." & module; + } + + throw( type="ModuleNotFound", message = "CBWIRE cannot locate the module '" & arguments.module & "'.") + } + + /** + * Returns the full dot notation path to a modules component. + * + * @path String | Name of the cbwire component. + * @module String | Name of the module to look for wire in. + */ + function getModuleComponentPath( path, module ) { + var moduleConfig = moduleService.getModuleConfigCache(); + if ( !moduleConfig.keyExists( module ) ) { + throw( type="ModuleNotFound", message = "CBWIRE cannot locate the module '" & arguments.module & "'.") + } + + // if there is a dot in the path, then we are referencing a folder within a module otherwise use the default wire location. + var moduleRegistry = moduleService.getModuleRegistry(); + + var result = arguments.path contains "." ? + moduleRegistry[ module ].invocationPath & "." & module & "." & arguments.path : + moduleRegistry[ module ].invocationPath & "." & module & "." & getWiresLocation() & "." & arguments.path; + + return result; + } + + /** + * Returns the path to the wires folder within a module path. + * + * @module string | The name of the module. + * + * @return string + */ + function getModuleWiresPath( module ) { + local.moduleRegistry = moduleService.getModuleRegistry(); + return arguments + } /** * Returns the ColdBox RequestContext object. @@ -442,4 +488,13 @@ component singleton { return requestService.getController().getRenderer().view( argumentCollection=arguments ); } + /** + * Returns the URI endpoint for updating CBWIRE components. + * + * @return string + */ + function getUpdateEndpoint() { + var settings = variables.moduleSettings; + return settings.keyExists( "updateEndpoint") && settings.updateEndpoint.len() ? settings.updateEndpoint : "/cbwire/update"; + } } \ No newline at end of file diff --git a/models/Component.cfc b/models/Component.cfc index 82a0e95..d251575 100644 --- a/models/Component.cfc +++ b/models/Component.cfc @@ -1,5 +1,7 @@ component output="true" { + property name="_globalSettings" inject="coldbox:modulesettings:cbwire"; + property name="_CBWIREController" inject="CBWIREController@cbwire"; property name="_wirebox" inject="wirebox"; @@ -24,6 +26,9 @@ component output="true" { property name="_returnValues"; property name="_redirect"; property name="_redirectUsingNavigate"; + property name="_isolate"; + property name="_path"; + property name="_renderedContent"; /** * Constructor @@ -58,6 +63,8 @@ component output="true" { variables._returnValues = []; variables._redirect = ""; variables._redirectUsingNavigate = false; + variables._isolate = false; + variables._renderedContent = ""; /* Cache the component's meta data on initialization @@ -80,6 +87,11 @@ component output="true" { */ _prepareGeneratedGettersAndSetters(); + /* + Prep isolation + */ + _prepareIsolation(); + /* Prep for lazy loading */ @@ -305,15 +317,27 @@ component output="true" { } // Instaniate this child component as a new component local.instance = variables._CBWIREController.createInstance(argumentCollection=arguments) - ._withParent( this ) - ._withEvent( variables._event ) - ._withParams( arguments.params, arguments.lazy ) - ._withKey( arguments.key ) - ._withLazy( arguments.lazy ); + ._withPath( arguments.name ) + ._withParent( this ) + ._withEvent( variables._event ) + ._withParams( arguments.params, arguments.lazy ) + ._withKey( arguments.key ) + ._withLazy( arguments.lazy ); // Check if lazy loading is enabled if ( arguments.lazy ) { - return local.instance._generateXIntersectLazyLoadSnapshot( params=arguments.params ); + local.lazyRendering = local.instance._generateXIntersectLazyLoadSnapshot( params=arguments.params ); + // Based on the rendering, determine our outer component tag + local.componentTag = _getComponentTag( local.lazyRendering ); + // Track the rendered child + variables._children.append( [ + "#arguments.key#": [ + local.componentTag, + local.instance._getId() + ] + ] ); + + return local.lazyRendering; } else { // Render it out normally local.rendering = local.instance._render(); @@ -556,10 +580,21 @@ component output="true" { } /** - * Passes the current event into our component. + * Passes the path of the component. + * + * @path string | The path of the component. * * @return Component + */ + function _withPath( path ) { + variables._path = arguments.path; + return this; + } + + /** + * Passes the current event into our component. * + * @return Component */ function _withEvent( event ) { variables._event = arguments.event; @@ -634,6 +669,7 @@ component output="true" { */ function _withLazy( lazy ) { variables._lazyLoad = arguments.lazy; + variables._isolate = true; return this; } @@ -705,10 +741,19 @@ component output="true" { * @return void */ function _applyUpdates( updates ) { + if ( !updates.count() ) return; + // Capture old values + local.oldValues = duplicate( data ); // Array to track which array props were updated local.updatedArrayProps = []; // Loop over the updates and apply them arguments.updates.each( function( key, value ) { + + // Check if we should trim if simple value + if ( isSimpleValue( arguments.value ) && shouldTrimStringValues() ) { + arguments.value = trim( arguments.value ); + } + // Determine if this is an array update if ( reFindNoCase( "\.[0-9]+", arguments.key ) ) { local.regexMatch = reFindNoCase( "(.+)\.([0-9]+)", arguments.key, 1, true ); @@ -720,7 +765,11 @@ component output="true" { updatedArrayProps.append( local.propertyName ); } } else { - variables.data[ key ] = value; + local.oldValue = variables.data[ key ]; + variables.data[ key ] = arguments.value; + if ( structKeyExists( this, "onUpdate#key#") ) { + invoke( this, "onUpdate#key#", { value: arguments.value, oldValue: local.oldValue }); + } } } ); @@ -729,6 +778,11 @@ component output="true" { return arguments.value != "__rm__"; } ); } ); + + // Call onUpdate passing newValues and oldValues + if ( structKeyExists( this, "onUpdate" ) ) { + invoke( this, "onUpdate", { newValues: duplicate( variables.data ), oldValues: local.oldValues } ); + } } /** @@ -809,7 +863,7 @@ component output="true" { local.normalizedPath &= ".cfm"; } // Ensure the path starts with "/wires/" without duplicating it - if (left(local.normalizedPath, 6) != "wires/") { + if (!isModulePath() && left(local.normalizedPath, 6) != "wires/") { local.normalizedPath = "wires/" & local.normalizedPath; } // Prepend a leading slash if not present @@ -952,8 +1006,17 @@ component output="true" { local.wireAttributes = 'wire:snapshot="' & arguments.snapshotEncoded & '" wire:effects="#_generateWireEffectsAttribute()#" wire:id="#variables._id#"'; // Determine our outer element local.outerElement = _getOuterElement( arguments.html ); + // Find the position of the opening tag + local.openingTagStart = findNoCase("<" & local.outerElement, arguments.html); + local.openingTagEnd = find(">", arguments.html, local.openingTagStart); // Insert attributes into the opening tag - return arguments.html.reReplaceNoCase( "<" & local.outerElement & "\s*", "<" & local.outerElement & " " & local.wireAttributes & " ", "one" ); + if (local.openingTagStart > 0 && local.openingTagEnd > 0) { + local.openingTag = mid(arguments.html, local.openingTagStart, local.openingTagEnd - local.openingTagStart + 1); + local.newOpeningTag = replace(local.openingTag, "<" & local.outerElement, "<" & local.outerElement & " " & local.wireAttributes, "one"); + arguments.html = replace(arguments.html, local.openingTag, local.newOpeningTag, "one"); + } + + return arguments.html; } /** @@ -1010,17 +1073,20 @@ component output="true" { * @return The rendered content of the view template. */ function _renderViewContent( normalizedPath, params = {} ){ - // Render our view using an renderer encapsulator - savecontent variable="local.viewContent" { - cfmodule( - template = "RendererEncapsulator.cfm", - cbwireComponent = this, - normalizedPath = arguments.normalizedPath, - params = arguments.params - ); + if ( !variables._renderedContent.len() ) { + // Render our view using an renderer encapsulator + savecontent variable="local.viewContent" { + cfmodule( + template = "RendererEncapsulator.cfm", + cbwireComponent = this, + normalizedPath = arguments.normalizedPath, + params = arguments.params + ); + } + variables._renderedContent = local.viewContent; } - return local.viewContent; + return variables._renderedContent; } /** @@ -1110,23 +1176,32 @@ component output="true" { }; // Prepend any passed in params into our forMount array - arguments.params.each( function( key, value ) { snapshot.data.forMount.prepend( { "#arguments.key#": arguments.value } ); } ); // Serialize the snapshot to JSON and then encode it for HTML attribute inclusion - var lazyLoadSnapshot = serializeJson(local.snapshot); + local.lazyLoadSnapshot = serializeJson( local.snapshot ); // Generate the base64 encoded version of the serialized snapshot for use in x-intersect - var base64EncodedSnapshot = toBase64(lazyLoadSnapshot); + local.base64EncodedSnapshot = toBase64( local.lazyLoadSnapshot ); - // Build the final
element with appropriate attributes - var lazyLoadDiv = '
#placeHolder()#
'; - - return lazyLoadDiv; + // Get our placeholder html + local.html = placeholder(); + + // Check if placeholder is even defined, if not throw error + if ( isNull( local.html ) || !local.html.len() ) { + throw( type="CBWIREException", message="The placeholder method must be defined for lazy loaded components and it must have the same outer element as your CBWIRE template." ); + } + + // Define the wire attributes to append + local.wireAttributes = 'wire:snapshot="' & _encodeAttribute( serializeJson( _getSnapshot() ) ) & '" wire:effects="#_generateWireEffectsAttribute()#" wire:id="#variables._id#"' & ' x-intersect="$wire._lazyMount(&##039;' & local.base64EncodedSnapshot & '&##039;)"'; + + // Determine our outer element + local.outerElement = _getOuterElement( local.html ); + + // Insert attributes into the opening tag + return local.html.reReplaceNoCase( "<" & local.outerElement & "\s*", "<" & local.outerElement & " " & local.wireAttributes & " ", "one" ); } /** @@ -1138,7 +1213,6 @@ component output="true" { * @return struct */ function _getHTTPResponse( componentPayload ){ - // Hydrate the component _hydrate( arguments.componentPayload ); // Apply any updates @@ -1321,6 +1395,17 @@ component output="true" { } ); } + /** + * Prepares the component for isolation. + * + * @return void + */ + function _prepareIsolation() { + // If the component has an isolate method, call it + variables._isolate = variables.keyExists( "isolate" ) && isBoolean( variables.isolate ) && variables.isolate ? + true : false; + } + /** * Prepares the component for lazy loading. * @@ -1330,6 +1415,10 @@ component output="true" { // If the component has a lazyLoad method, call it variables._lazyLoad = variables.keyExists( "lazyLoad" ) && isBoolean( variables.lazyLoad ) && variables.lazyLoad ? true : false; + + if ( variables._lazyLoad ) { + variables._isolate = true; + } } /** @@ -1360,7 +1449,21 @@ component output="true" { * Returns the path to the view template file. */ function _getViewPath(){ - return "wires." & _getComponentName(); + if ( isModulePath() ) { + var moduleRoot = variables._CBWIREController.getModuleRootPath( _getModuleName() ); + return moduleRoot & ".wires." & _getComponentName().listFirst( "@" ); + } + + return "wires." & variables._path; + } + + /** + * Returns the module name. + * + * @return string + */ + function _getModuleName() { + return variables._path contains "@" ? variables._path.listLast( "@" ) : ""; } /** @@ -1385,15 +1488,15 @@ component output="true" { * @return struct */ function _getMemo(){ - var name = _getComponentName(); return [ "id": variables._id, - "name":name, - "path":name, + "name": _getComponentName(), + "path": _getComponentName(), "method":"GET", "children": variables._children.count() ? variables._children : [], "scripts":[], "assets":[], + "isolate": variables._isolate, "lazyLoaded": false, "lazyIsolated": true, "errors":[], @@ -1411,7 +1514,8 @@ component output="true" { if ( variables._metaData.name contains "cbwire.models.tmp." ) { return variables._metaData.name.replaceNoCase( "cbwire.models.tmp.", "", "one" ); } - return variables._metaData.name.replaceNoCase( "wires.", "", "one" ); + // only returns the last part of the name seprate by dots + return variables._path; } /** @@ -1510,6 +1614,15 @@ component output="true" { return local.outerElement.trim(); } + /** + * Returns true if the path contains a module. + * + * @return boolean + */ + function isModulePath() { + return variables._path contains "@"; + } + /** * Returns true if the cbvalidation module is installed. * @@ -1523,4 +1636,16 @@ component output="true" { return false; } } + + /** + * Returns true if trimStringValues is enabled, either globally + * or for the component. + * + * @return boolean + */ + function shouldTrimStringValues() { + return + ( _globalSettings.keyExists( "trimStringValues" ) && _globalSettings.trimStringValues == true ) || + ( variables.keyExists( "trimStringValues" ) && variables.trimStringValues == true ); + } } diff --git a/models/RendererEncapsulator.cfm b/models/RendererEncapsulator.cfm index 923af3e..7cdc887 100644 --- a/models/RendererEncapsulator.cfm +++ b/models/RendererEncapsulator.cfm @@ -1,7 +1,6 @@ function fileIsOutdated(sourcePath, cachePath) { - return true; return getFileInfo(sourcePath).lastModified > getFileInfo(cachePath).lastModified; } diff --git a/models/SingleFileComponentBuilder.cfc b/models/SingleFileComponentBuilder.cfc index f809f9c..eacfe4d 100644 --- a/models/SingleFileComponentBuilder.cfc +++ b/models/SingleFileComponentBuilder.cfc @@ -93,12 +93,6 @@ component accessors="true" singleton { return { "tempComponentName" : "#arguments.componentName#" }; } } - - // if ( structKeyExists( settings, "cacheSingleFileComponents" ) && settings.cacheSingleFileComponents ) { - // if ( fileExists( local.tmpCFCPath ) && fileExists( local.tmpCFMPath ) ) { - // return { "tempComponentName" : "#arguments.componentName#" }; - // } - // } if ( !directoryExists( local.tmpDirectory ) ) { directoryCreate( local.tmpDirectory ); diff --git a/models/scripts.cfm b/models/scripts.cfm index ad1ecb7..de98943 100644 --- a/models/scripts.cfm +++ b/models/scripts.cfm @@ -1,6 +1,6 @@ - +