diff --git a/modules/cms/assets/css/october.components.css b/modules/cms/assets/css/october.components.css index a351c4e39..878e71ef0 100644 --- a/modules/cms/assets/css/october.components.css +++ b/modules/cms/assets/css/october.components.css @@ -134,6 +134,15 @@ div.control-componentlist div.components div.layout-cell.error-component > div { div.control-componentlist div.components div.layout-cell.error-component > div:after { color: #ab2a1c; } +div.control-componentlist div.components div.layout-cell.warning-component { + background: #ffc107; +} +div.control-componentlist div.components div.layout-cell.warning-component > div { + color: #343a40; +} +div.control-componentlist div.components div.layout-cell.warning-component > div:after { + color: #ffc107; +} div.control-componentlist div.components div.layout-cell:first-child { border-bottom-left-radius: 3px; border-top-left-radius: 3px; diff --git a/modules/cms/assets/js/october.cmspage.js b/modules/cms/assets/js/october.cmspage.js index e4721abad..6e6735efd 100644 --- a/modules/cms/assets/js/october.cmspage.js +++ b/modules/cms/assets/js/october.cmspage.js @@ -436,7 +436,7 @@ */ var editor = $('[data-control=codeeditor]', pane) if (editor.length) { - var alias = $('input[name="component_aliases[]"]', component).val(), + var alias = $('input[name="component_aliases[]"]', component).val().replace(/^@/, ''), codeEditor = editor.codeEditor('getEditorObject') codeEditor.replace('', { @@ -715,4 +715,4 @@ } $.oc.cmsPage = new CmsPage(); -}(window.jQuery); \ No newline at end of file +}(window.jQuery); diff --git a/modules/cms/assets/less/october.components.less b/modules/cms/assets/less/october.components.less index 93dea2c33..5f8edb0c9 100644 --- a/modules/cms/assets/less/october.components.less +++ b/modules/cms/assets/less/october.components.less @@ -13,6 +13,8 @@ @color-group-bg: #f1f3f4; @color-error-component-bg: #ab2a1c; @color-error-component-text: #ffffff; +@color-warning-component-bg: #ffc107; +@color-warning-component-text: #343a40; .component-lego-icon() { position: absolute; @@ -139,6 +141,18 @@ div.control-componentlist { } } + &.warning-component { + background: @color-warning-component-bg; + + > div { + color: @color-warning-component-text; + + &:after { + color: @color-warning-component-bg; + } + } + } + &:first-child { .border-left-radius(3px); } diff --git a/modules/cms/classes/ComponentHelpers.php b/modules/cms/classes/ComponentHelpers.php index 682c4208e..04660d7b0 100644 --- a/modules/cms/classes/ComponentHelpers.php +++ b/modules/cms/classes/ComponentHelpers.php @@ -27,7 +27,7 @@ class ComponentHelpers 'title' => Lang::get('cms::lang.component.alias'), 'description' => Lang::get('cms::lang.component.alias_description'), 'type' => 'string', - 'validationPattern' => '^[a-zA-Z]+[0-9a-z\_]*$', + 'validationPattern' => '^(@)?[a-zA-Z]+[0-9a-z\_]*$', 'validationMessage' => Lang::get('cms::lang.component.validation_message'), 'required' => true, 'showExternalParam' => false diff --git a/modules/cms/classes/ComponentManager.php b/modules/cms/classes/ComponentManager.php index fe220c1b1..47d3d0050 100644 --- a/modules/cms/classes/ComponentManager.php +++ b/modules/cms/classes/ComponentManager.php @@ -187,32 +187,39 @@ class ComponentManager /** * Makes a component object with properties set. + * * @param string $name A component class name or code. * @param CmsObject $cmsObject The Cms object that spawned this component. * @param array $properties The properties set by the Page or Layout. + * @param bool $isSoftComponent Defines if this is a soft component. + * * @return ComponentBase The component object. + * @throws SystemException If the (hard) component cannot be found or is not registered. */ - public function makeComponent($name, $cmsObject = null, $properties = []) + public function makeComponent($name, $cmsObject = null, $properties = [], $isSoftComponent = false) { - $className = $this->resolve($name); - if (!$className) { + $className = $this->resolve(ltrim($name, '@')); + + if (!$className && !$isSoftComponent) { throw new SystemException(sprintf( 'Class name is not registered for the component "%s". Check the component plugin.', $name )); } - if (!class_exists($className)) { + if (!class_exists($className) && !$isSoftComponent) { throw new SystemException(sprintf( 'Component class not found "%s". Check the component plugin.', $className )); } - $component = App::make($className, [$cmsObject, $properties]); - $component->name = $name; + if (class_exists($className)) { + $component = App::make($className, [$cmsObject, $properties]); + $component->name = $name; - return $component; + return $component; + } } /** diff --git a/modules/cms/classes/Controller.php b/modules/cms/classes/Controller.php index 9bcdc77bd..30ad84c36 100644 --- a/modules/cms/classes/Controller.php +++ b/modules/cms/classes/Controller.php @@ -11,6 +11,7 @@ use Session; use Request; use Response; use Exception; +use SystemException; use BackendAuth; use Twig\Environment as TwigEnvironment; use Twig\Cache\FilesystemCache as TwigCacheFilesystem; @@ -24,7 +25,6 @@ use System\Classes\CombineAssets; use System\Twig\Extension as SystemTwigExtension; use October\Rain\Exception\AjaxException; use October\Rain\Exception\ValidationException; -use October\Rain\Exception\ApplicationException; use October\Rain\Parse\Bracket as TextParser; use Illuminate\Http\RedirectResponse; @@ -1403,36 +1403,56 @@ class Controller // /** - * Adds a component to the page object - * @param mixed $name Component class name or short name - * @param string $alias Alias to give the component - * @param array $properties Component properties - * @param bool $addToLayout Add to layout, instead of page - * @return ComponentBase Component object + * Adds a component to the page object. + * + * @param mixed $name Component class name or short name + * @param string $alias Alias to give the component + * @param array $properties Component properties + * @param bool $addToLayout Add to layout, instead of page + * + * @return ComponentBase|null Component object. Will return `null` if a soft component is used but not found. + * + * @throws CmsException if the (hard) component is not found. + * @throws SystemException if the (hard) component class is not found or is not registered. */ public function addComponent($name, $alias, $properties, $addToLayout = false) { $manager = ComponentManager::instance(); + $isSoftComponent = $this->isSoftComponent($name); + + if ($isSoftComponent) { + $name = $this->parseComponentLabel($name); + $alias = $this->parseComponentLabel($alias); + } + + $componentObj = $manager->makeComponent( + $name, + ($addToLayout) ? $this->layoutObj : $this->pageObj, + $properties, + $isSoftComponent + ); + + if (is_null($componentObj)) { + if (!$isSoftComponent) { + throw new CmsException(Lang::get('cms::lang.component.not_found', ['name' => $name])); + } + + // A missing soft component will return null. + return null; + } + + $componentObj->alias = $alias; + $this->vars[$alias] = $componentObj; if ($addToLayout) { - if (!$componentObj = $manager->makeComponent($name, $this->layoutObj, $properties)) { - throw new CmsException(Lang::get('cms::lang.component.not_found', ['name'=>$name])); - } - - $componentObj->alias = $alias; - $this->vars[$alias] = $this->layout->components[$alias] = $componentObj; - } - else { - if (!$componentObj = $manager->makeComponent($name, $this->pageObj, $properties)) { - throw new CmsException(Lang::get('cms::lang.component.not_found', ['name'=>$name])); - } - - $componentObj->alias = $alias; - $this->vars[$alias] = $this->page->components[$alias] = $componentObj; + $this->layout->components[$alias] = $componentObj; + } else { + $this->page->components[$alias] = $componentObj; } $this->setComponentPropertiesFromParams($componentObj); $componentObj->init(); + return $componentObj; } @@ -1546,4 +1566,27 @@ class Controller } } } + + /** + * Removes prefixed '@' from soft component name + * @param string $label + * @return string + */ + protected function parseComponentLabel($label) + { + if ($this->isSoftComponent($label)) { + return ltrim($label, '@'); + } + return $label; + } + + /** + * Checks if component name has @. + * @param string $label + * @return bool + */ + protected function isSoftComponent($label) + { + return starts_with($label, '@'); + } } diff --git a/modules/cms/components/SoftComponent.php b/modules/cms/components/SoftComponent.php new file mode 100644 index 000000000..bcbb37de3 --- /dev/null +++ b/modules/cms/components/SoftComponent.php @@ -0,0 +1,33 @@ +componentCssClass = 'warning-component'; + $this->inspectorEnabled = false; + + parent::__construct(null, $properties); + } + + /** + * @return array + */ + public function componentDetails() + { + return [ + 'name' => 'cms::lang.component.soft_component', + 'description' => 'cms::lang.component.soft_component_description' + ]; + } +} diff --git a/modules/cms/controllers/Index.php b/modules/cms/controllers/Index.php index 07af395c1..617e91bc3 100644 --- a/modules/cms/controllers/Index.php +++ b/modules/cms/controllers/Index.php @@ -191,7 +191,7 @@ class Index extends Controller $templateData = []; $settings = array_get($saveData, 'settings', []) + Request::input('settings', []); - $settings = $this->upgradeSettings($settings); + $settings = $this->upgradeSettings($settings, $template->settings); if ($settings) { $templateData['settings'] = $settings; @@ -556,7 +556,7 @@ class Index extends Controller } /** - * Reolves a template type to its class name + * Resolves a template type to its class name * @param string $type * @return string */ @@ -687,11 +687,12 @@ class Index extends Controller } /** - * Processes the component settings so they are ready to be saved - * @param array $settings + * Processes the component settings so they are ready to be saved. + * @param array $settings The new settings for this template. + * @param array $prevSettings The previous settings for this template. * @return array */ - protected function upgradeSettings($settings) + protected function upgradeSettings($settings, $prevSettings) { /* * Handle component usage @@ -714,14 +715,34 @@ class Index extends Controller $componentName = $componentNames[$index]; $componentAlias = $componentAliases[$index]; - $section = $componentName; - if ($componentAlias != $componentName) { - $section .= ' '.$componentAlias; + $isSoftComponent = (substr($componentAlias, 0, 1) === '@'); + $componentName = ltrim($componentName, '@'); + $componentAlias = ltrim($componentAlias, '@'); + + if ($componentAlias !== $componentName) { + $section = $componentName . ' ' . $componentAlias; + } else { + $section = $componentName; + } + if ($isSoftComponent) { + $section = '@' . $section; } $properties = json_decode($componentProperties[$index], true); unset($properties['oc.alias'], $properties['inspectorProperty'], $properties['inspectorClassName']); - $settings[$section] = $properties; + + if (!$properties) { + $oldComponentSettings = array_key_exists($section, $prevSettings['components']) + ? $prevSettings['components'][$section] + : null; + if ($isSoftComponent && $oldComponentSettings) { + $settings[$section] = $oldComponentSettings; + } else { + $settings[$section] = $properties; + } + } else { + $settings[$section] = $properties; + } } } @@ -756,6 +777,35 @@ class Index extends Controller return $dataHolder->settings; } + /** + * Finds a given component by its alias. + * + * If found, this will return the component's name, alias and properties. + * + * @param string $aliasQuery The alias to search for + * @param array $components The array of components to look within. + * @return array|null + */ + protected function findComponentByAlias(string $aliasQuery, array $components = []) + { + $found = null; + + foreach ($components as $name => $properties) { + list($name, $alias) = strpos($name, ' ') ? explode(' ', $name) : [$name, $name]; + + if (ltrim($alias, '@') === ltrim($aliasQuery, '@')) { + $found = [ + 'name' => ltrim($name, '@'), + 'alias' => $alias, + 'properties' => $properties + ]; + break; + } + } + + return $found; + } + /** * Binds the active form widget to the controller * @return void diff --git a/modules/cms/formwidgets/Components.php b/modules/cms/formwidgets/Components.php index de3051e3c..b3931cc8f 100644 --- a/modules/cms/formwidgets/Components.php +++ b/modules/cms/formwidgets/Components.php @@ -3,6 +3,7 @@ use Backend\Classes\FormWidgetBase; use Cms\Classes\ComponentManager; use Cms\Classes\ComponentHelpers; +use Cms\Components\SoftComponent; use Cms\Components\UnknownComponent; use Exception; @@ -41,8 +42,7 @@ class Components extends FormWidgetBase try { $componentObj = $manager->makeComponent($name, null, $properties); - - $componentObj->alias = $alias; + $componentObj->alias = ((starts_with($name, '@') && $alias !== $name) ? '@' : '') . $alias; $componentObj->pluginIcon = 'icon-puzzle-piece'; /* @@ -57,9 +57,16 @@ class Components extends FormWidgetBase } } catch (Exception $ex) { - $componentObj = new UnknownComponent(null, $properties, $ex->getMessage()); - $componentObj->alias = $alias; - $componentObj->pluginIcon = 'icon-bug'; + if (starts_with($name, '@')) { + $componentObj = new SoftComponent($properties); + $componentObj->name = $name; + $componentObj->alias = (($alias !== $name) ? '@' : '') . $alias; + $componentObj->pluginIcon = 'icon-flag'; + } else { + $componentObj = new UnknownComponent(null, $properties, $ex->getMessage()); + $componentObj->alias = $alias; + $componentObj->pluginIcon = 'icon-bug'; + } } $result[] = $componentObj; diff --git a/modules/cms/lang/en/lang.php b/modules/cms/lang/en/lang.php index 8100a657d..66bad48cb 100644 --- a/modules/cms/lang/en/lang.php +++ b/modules/cms/lang/en/lang.php @@ -250,6 +250,8 @@ return [ 'no_records' => 'No components found', 'not_found' => "The component ':name' is not found.", 'method_not_found' => "The component ':name' does not contain a method ':method'.", + 'soft_component' => 'Soft Component', + 'soft_component_description' => 'This component is missing but optional.', ], 'template' => [ 'invalid_type' => 'Unknown template type.', diff --git a/tests/fixtures/themes/test/pages/no-soft-component-class.htm b/tests/fixtures/themes/test/pages/no-soft-component-class.htm new file mode 100644 index 000000000..9559b16dc --- /dev/null +++ b/tests/fixtures/themes/test/pages/no-soft-component-class.htm @@ -0,0 +1,5 @@ +url = "/no-soft-component-class" + +[@PeterPan\Nevernever\Land noComponentExist] +== +

Hey

\ No newline at end of file diff --git a/tests/fixtures/themes/test/pages/with-soft-component-class-alias.htm b/tests/fixtures/themes/test/pages/with-soft-component-class-alias.htm new file mode 100644 index 000000000..072b1e214 --- /dev/null +++ b/tests/fixtures/themes/test/pages/with-soft-component-class-alias.htm @@ -0,0 +1,11 @@ +url = "/with-soft-component-class-alias" +layout = "content" + +[@testArchive someAlias] +posts-per-page = "69" +== +

This page uses components.

+{% for post in someAlias.posts %} +

{{ post.title }}

+

{{ post.content }}

+{% endfor %} diff --git a/tests/fixtures/themes/test/pages/with-soft-component-class.htm b/tests/fixtures/themes/test/pages/with-soft-component-class.htm new file mode 100644 index 000000000..330dc565e --- /dev/null +++ b/tests/fixtures/themes/test/pages/with-soft-component-class.htm @@ -0,0 +1,11 @@ +url = "/with-soft-component-class" +layout = "content" + +[@testArchive] +posts-per-page = "69" +== +

This page uses components.

+{% for post in testArchive.posts %} +

{{ post.title }}

+

{{ post.content }}

+{% endfor %} diff --git a/tests/unit/cms/classes/CmsObjectQueryTest.php b/tests/unit/cms/classes/CmsObjectQueryTest.php index d43092332..978745ae1 100644 --- a/tests/unit/cms/classes/CmsObjectQueryTest.php +++ b/tests/unit/cms/classes/CmsObjectQueryTest.php @@ -78,6 +78,7 @@ class CmsObjectQueryTest extends TestCase "no-component-class", "no-layout", "no-partial", + "no-soft-component-class", "optional-full-php-tags", "optional-short-php-tags", "throw-php", @@ -87,6 +88,8 @@ class CmsObjectQueryTest extends TestCase "with-layout", "with-partials", "with-placeholder", + "with-soft-component-class", + "with-soft-component-class-alias", ], $pages); $layouts = Layout::lists('baseFileName'); diff --git a/tests/unit/cms/classes/ControllerTest.php b/tests/unit/cms/classes/ControllerTest.php index 14db9494a..02b2abc34 100644 --- a/tests/unit/cms/classes/ControllerTest.php +++ b/tests/unit/cms/classes/ControllerTest.php @@ -381,6 +381,67 @@ ESC; $response = $controller->run('/no-component-class')->getContent(); } + public function testSoftComponentClassNotFound() + { + $theme = Theme::load('test'); + $controller = new Controller($theme); + $response = $controller->run('/no-soft-component-class')->getContent(); + + $this->assertEquals('

Hey

', $response); + } + + public function testSoftComponentClassFound() + { + $theme = Theme::load('test'); + $controller = new Controller($theme); + $response = $controller->run('/with-soft-component-class')->getContent(); + $page = $this->readAttribute($controller, 'page'); + $this->assertArrayHasKey('testArchive', $page->components); + + $component = $page->components['testArchive']; + $details = $component->componentDetails(); + + $content = <<LAYOUT CONTENT

This page uses components.

+

Lorum ipsum

+

Post Content #1

+

La Playa Nudista

+

Second Post Content

+ +ESC; + + $this->assertEquals($content, $response); + $this->assertEquals(69, $component->property('posts-per-page')); + $this->assertEquals('Blog Archive Dummy Component', $details['name']); + $this->assertEquals('Displays an archive of blog posts.', $details['description']); + } + + public function testSoftComponentWithAliasClassFound() + { + $theme = Theme::load('test'); + $controller = new Controller($theme); + $response = $controller->run('/with-soft-component-class-alias')->getContent(); + $page = $this->readAttribute($controller, 'page'); + $this->assertArrayHasKey('someAlias', $page->components); + + $component = $page->components['someAlias']; + $details = $component->componentDetails(); + + $content = <<LAYOUT CONTENT

This page uses components.

+

Lorum ipsum

+

Post Content #1

+

La Playa Nudista

+

Second Post Content

+ +ESC; + + $this->assertEquals($content, $response); + $this->assertEquals(69, $component->property('posts-per-page')); + $this->assertEquals('Blog Archive Dummy Component', $details['name']); + $this->assertEquals('Displays an archive of blog posts.', $details['description']); + } + public function testComponentNotFound() { //