├── .gitignore ├── lib ├── src │ ├── string_case_operations.dart │ ├── class_dynamic_operations.dart │ └── dom_virtual_container.dart ├── component_temple.dart ├── template_stack.dart ├── template.dart └── component.dart ├── test ├── dom_virtual_container.html ├── runner.html ├── dom_virtual_container_test.dart └── component_temple_test.dart ├── pubspec.lock └── pubspec.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | packages 2 | -------------------------------------------------------------------------------- /lib/src/string_case_operations.dart: -------------------------------------------------------------------------------- 1 | part of component_temple; 2 | 3 | capitalizeFirstLetter(s) { 4 | return s[0].toUpperCase() + s.substring(1); 5 | } 6 | 7 | downcaseFirstLetter(s) { 8 | return s[0].toLowerCase() + s.substring(1); 9 | } 10 | 11 | dashedToCamelcase(str) { 12 | var result = ''; 13 | str.split('-').forEach((s) { 14 | result += capitalizeFirstLetter(s); 15 | }); 16 | return result; 17 | } 18 | -------------------------------------------------------------------------------- /test/dom_virtual_container.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 |
14 |
15 | 16 |
17 |
18 | 19 | 20 |
21 |
22 | 23 |
24 | 25 |
26 | 27 |
28 |
29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /lib/component_temple.dart: -------------------------------------------------------------------------------- 1 | library component_temple; 2 | 3 | // vendor libs 4 | import 'dart:html' ; 5 | import 'dart:mirrors'; 6 | import 'dart:math' ; 7 | 8 | // local libs 9 | import 'package:observable_roles/observable_roles.dart' as observable; 10 | import 'package:attributable/attributable.dart'; 11 | import 'package:validatable/validatable.dart'; 12 | 13 | // parts of the current lib 14 | part 'src/string_case_operations.dart'; 15 | part 'src/dom_virtual_container.dart' ; 16 | part 'src/class_dynamic_operations.dart'; 17 | part 'template_stack.dart' ; 18 | part 'template.dart' ; 19 | part 'component.dart' ; 20 | 21 | assembleComponents(doc) { 22 | TemplateStack.collect(doc); 23 | TemplateStack.buildComponents(doc); 24 | } 25 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See http://pub.dartlang.org/doc/glossary.html#lockfile 3 | packages: 4 | attributable: 5 | description: 6 | path: "../attributable" 7 | relative: true 8 | source: path 9 | version: "0.1.0" 10 | matcher: 11 | description: matcher 12 | source: hosted 13 | version: "0.10.1+1" 14 | observable_roles: 15 | description: 16 | path: "../observable_roles" 17 | relative: true 18 | source: path 19 | version: "0.1.0" 20 | path: 21 | description: path 22 | source: hosted 23 | version: "1.2.1" 24 | stack_trace: 25 | description: stack_trace 26 | source: hosted 27 | version: "1.0.0" 28 | unittest: 29 | description: unittest 30 | source: hosted 31 | version: "0.11.0+2" 32 | validatable: 33 | description: 34 | path: "../validatable" 35 | relative: true 36 | source: path 37 | version: "0.1.0" 38 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: component_temple 2 | version: 0.1.0 3 | description: > 4 | A library for creating architecturally beautiful 5 | GUI interfaces. The central idea of it is an hierarchy 6 | of components reacting to events and behaving themselves 7 | in various different ways visible to users. 8 | 9 | It can be used as a stand alone library, both on one-page webapps or 10 | on ones with multiple pages (content oriented websites that care 11 | about SEO). 12 | 13 | Read a very thorough (but simple to follow!) explanation 14 | on the project homepage. 15 | 16 | author: Roman Snitko 17 | homepage: http://temple.snitko.ru/component-temple 18 | documentation: http://temple.snitko.ru/component-temple/doc 19 | 20 | dependencies: 21 | attributable: 22 | path: '../attributable/' 23 | observable_roles: 24 | path: '../observable_roles/' 25 | validatable: 26 | path: '../validatable/' 27 | 28 | dev_dependencies: 29 | unittest: any 30 | -------------------------------------------------------------------------------- /lib/src/class_dynamic_operations.dart: -------------------------------------------------------------------------------- 1 | part of component_temple; 2 | 3 | List findSubclasses(name) { 4 | 5 | final ms = currentMirrorSystem(); 6 | List subclasses = []; 7 | 8 | ms.libraries.forEach((k,lib) { 9 | lib.declarations.forEach((k2, c) { 10 | if(c is ClassMirror && c.superclass != null) { 11 | final parentClassName = MirrorSystem.getName(c.superclass.simpleName); 12 | if (parentClassName == name) { 13 | subclasses.add(c); 14 | } 15 | } 16 | }); 17 | }); 18 | 19 | return subclasses; 20 | 21 | } 22 | 23 | new_instance_of(String class_name, String library) { 24 | 25 | MirrorSystem mirrors = currentMirrorSystem(); 26 | LibraryMirror lm = mirrors.libraries.values.firstWhere( 27 | (LibraryMirror lm) => lm.qualifiedName == new Symbol(library) 28 | ); 29 | 30 | ClassMirror cm = lm.declarations[new Symbol(class_name)]; 31 | 32 | InstanceMirror im = cm.newInstance(new Symbol(''), []); 33 | return im.reflectee; 34 | 35 | } 36 | -------------------------------------------------------------------------------- /lib/template_stack.dart: -------------------------------------------------------------------------------- 1 | part of component_temple; 2 | 3 | class TemplateStack { 4 | 5 | static Map templates = new Map(); 6 | 7 | static collect(fragment) { 8 | fragment.querySelectorAll('templates>template').forEach((t) { 9 | TemplateStack.templates[t.attributes['name']] = new Template(t); 10 | }); 11 | } 12 | 13 | static buildComponents(container, [parent_component=null]) { 14 | TemplateStack.templates.forEach((k,t) { 15 | t.buildComponents(container, parent_component); 16 | }); 17 | } 18 | 19 | static findComponentForDomElement(el) { 20 | var component; 21 | var component_name = el.dataset['componentName']; 22 | var template_name = component_name.split('_')[0]; 23 | TemplateStack.templates.forEach((k,t) { 24 | if(t.name == template_name) { 25 | t.components.forEach((c_name, c_instance) { 26 | if(c_name == component_name) { component = c_instance; } 27 | }); 28 | } 29 | }); 30 | return component; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /test/runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 19 | 20 | 23 | 24 | 27 | 28 | 37 | 38 | 44 | 45 | 48 | 49 | 52 | 53 | 54 | 55 |
56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | this is mock 66 | 67 | 68 | 69 | 70 | caption 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /test/dom_virtual_container_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:html'; 2 | import 'package:unittest/unittest.dart'; 3 | import 'package:unittest/mock.dart'; 4 | import 'package:unittest/html_config.dart'; 5 | import 'dart:mirrors'; 6 | import '../component_temple.dart'; 7 | 8 | main() { 9 | 10 | useHtmlConfiguration(); 11 | 12 | group('DOMVirtualContainer', () { 13 | 14 | var button = document.querySelector('.button div'); 15 | var ic = new DOMVirtualContainer.find('Component_content', button); 16 | var added_element; 17 | 18 | tearDown(() { 19 | if(added_element != null) { 20 | added_element.remove(); 21 | added_element = null; 22 | } 23 | }); 24 | 25 | test('finds invisible container opening comment tag and creates a closing tag for it', () { 26 | 27 | var comment_nodes = []; 28 | button.childNodes.forEach((n) { 29 | if(n.nodeType == 8) { comment_nodes.add(n); } 30 | }); 31 | expect(comment_nodes.length, equals(2)); 32 | expect(comment_nodes[0].nodeValue, equals('Component_content')); 33 | expect(comment_nodes[1].nodeValue, equals('END-OF-Component_content')); 34 | 35 | }); 36 | 37 | 38 | test('adds child element to the bottom', () { 39 | ic.prepend(new Element.html("
")); 40 | added_element = button.querySelector('div#child1'); 41 | expect(added_element, isNotNull); 42 | expect(added_element.previousNode.nodeValue, equals('Component_content')); 43 | }); 44 | 45 | test('adds child element to the top', () { 46 | ic.append(new Element.html("
")); 47 | added_element = button.querySelector('div#child2'); 48 | expect(added_element, isNotNull); 49 | expect(added_element.nextNode.nodeValue, equals('END-OF-Component_content')); 50 | }); 51 | 52 | test('gets a list of children', () { 53 | ic.append(new Element.html("
")); 54 | ic.append(new Element.html("
")); 55 | expect(ic.children.length, equals(2)); 56 | 57 | //cleaning up 58 | ic.children.forEach((c) => c.remove()); 59 | }); 60 | 61 | test('finds multiple invisible containers with names specified by a regexp', () { 62 | var button2 = document.querySelector('.button2 div'); 63 | var containers = new DOMVirtualContainer.findAll('Component_property.*', button2); 64 | expect(containers.length, equals(2)); 65 | containers.forEach((c) => expect(c is DOMVirtualContainer, isTrue)); 66 | containers = new DOMVirtualContainer.findAll('Component_property:loc.*', button2); 67 | expect(containers.length, equals(1)); 68 | }); 69 | 70 | 71 | // This is useful when we, for example, do not wish to look inside the virtual cotainers 72 | // of the children components. 73 | test('stops looking into child nodes if the node qualifies as a stop point', () { 74 | var parent = document.querySelector('.parent'); 75 | var containers = new DOMVirtualContainer.findAll('Component_property.*', parent, skip_children: ['data-component-name']); 76 | expect(containers.length, equals(1)); 77 | }); 78 | 79 | test('sets text value for itself', () { 80 | ic.text = 'new caption'; 81 | expect(ic.children[0].text, equals('new caption')); 82 | }); 83 | 84 | }); 85 | 86 | } 87 | -------------------------------------------------------------------------------- /lib/template.dart: -------------------------------------------------------------------------------- 1 | part of component_temple; 2 | 3 | class Template { 4 | 5 | static NodeValidatorBuilder nodeValidator() { 6 | return new NodeValidatorBuilder() 7 | ..allowHtml5() 8 | ..allowElement('div', attributes: ['data-component-id' ]) 9 | ..allowElement('div', attributes: ['data-component-content' ]) 10 | ..allowElement('div', attributes: ['data-event-listener' ]) 11 | ..allowElement('input', attributes: ['data-event-listener' ]) 12 | ..allowElement('div', attributes: ['data-component-children']) 13 | ..allowElement('span', attributes: ['data-component-property']); 14 | } 15 | 16 | static final Map componentClasses = findSubclasses("Component"); 17 | 18 | Element element ; 19 | String html ; 20 | String name ; 21 | Component componentClass ; 22 | String componentClassName ; 23 | Map components = {} ; 24 | num last_component_id = 0; 25 | String numeration_type = 'consecutive'; 26 | 27 | Template(this.element) { 28 | 29 | this.html = this.element.innerHtml; 30 | this.name = capitalizeFirstLetter(this.element.attributes['name']); 31 | 32 | if(this.element.dataset['numerationType'] != null) { 33 | this.numeration_type = this.element.dataset['numerationType']; 34 | } 35 | 36 | /*****************************************************/ 37 | /* Determining Component subclass for this template. */ 38 | 39 | /* Sometimes you want your template to have a different name, but use the same Component subclass. 40 | So you simply add a component-class-name attr to it. Usecase would be a button of a different color. 41 | */ 42 | if(this.element.attributes['component-class-name'] != null) { 43 | this.componentClassName = "${capitalizeFirstLetter(this.element.attributes['component-class-name'])}Component"; 44 | } else { 45 | this.componentClassName = "${dashedToCamelcase(this.name)}Component"; 46 | } 47 | 48 | // If no custom Component subclass for this template found, use Component 49 | try { 50 | this.componentClass = componentClasses 51 | .firstWhere((cm) => cm.simpleName == new Symbol(this.componentClassName)); 52 | } on StateError { 53 | this.componentClass = reflectClass(Component); 54 | this.componentClassName = 'Component'; 55 | } 56 | 57 | /***************************************************/ 58 | /***************************************************/ 59 | 60 | } 61 | 62 | buildComponents(container, [parent_component=null]) { 63 | 64 | container.children.forEach((c) { 65 | 66 | var node_name = dashedToCamelcase(c.nodeName.toLowerCase()); 67 | 68 | if(c.nodeType == 1) { 69 | 70 | // It's our component - let's build it! 71 | if(node_name == this.name) { 72 | var component = this.componentClass.newInstance(new Symbol(''), [this, c]).reflectee; 73 | if(parent_component != null) component.parent = parent_component; 74 | addComponent(component); 75 | TemplateStack.buildComponents(component.element, component); 76 | } 77 | 78 | // Not a component at all, regular element - let's look inside! 79 | else if (!TemplateStack.templates.keys.contains(downcaseFirstLetter(node_name)) && c.dataset['componentName'] == null) { 80 | buildComponents(c, parent_component); 81 | } 82 | 83 | } 84 | }); 85 | 86 | } 87 | 88 | addComponent(c) { 89 | this.components[c.name] = c; 90 | if(numeration_type != 'random') { this.last_component_id += 1; } 91 | } 92 | 93 | buildDomElement() { 94 | 95 | var dom_element; 96 | try { 97 | dom_element = new Element.html(this.html, validator: Template.nodeValidator()); 98 | } catch(e) { 99 | if(e.message == "More than one element") { 100 | throw new Exception("Template ${this.name} has more than one immediate DOM-child. Templates must have only one child DOM element."); 101 | } 102 | } 103 | 104 | return dom_element; 105 | 106 | } 107 | 108 | next_component_number() { 109 | if(numeration_type == 'random') 110 | return _generateAltNumeration(); 111 | else 112 | return last_component_id+1; 113 | } 114 | 115 | _generateAltNumeration() { 116 | var t = new DateTime.now().millisecondsSinceEpoch; 117 | var r = new Random().nextInt(1000000); 118 | return("${t}-${r}"); 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /lib/src/dom_virtual_container.dart: -------------------------------------------------------------------------------- 1 | part of component_temple; 2 | 3 | /* This class is a confession in deficiency of HTML and the DOM model. 4 | Imagine you have a . You'd like to dynamycally put some child elements into it, 5 | but put them in a specific place. For example: 6 | 7 |
8 | 9 | -- PUT ROWS HERE -- 10 |
11 | 12 | How do you do it? Well, one way is you can use an element like
or instead 13 | of the -- PUT ROWS HERE -- marker. Like this: 14 | 15 | 16 | 17 |
18 |
19 | 20 | But whoops, the problem is, when your browser parses the code and finds that
21 | it thinks "fuck you, you stupid web developer, it's not supposed to be here." 22 | And guess what you end up with? This: 23 | 24 |
25 | 26 | 27 |
28 | 29 | So what do you do? Well, supposedly you could use something like instead of that div. 30 | But, of course, that is hardly a general solution, since there might be other elements with some 31 | other restrictions. Furthermore, a browser might actually render that wrapper element in some 32 | unpredicatable way. This is why we need some wrapper that browser ignores, but in which we can 33 | still put some elements. Comments are almost perfect for this. Check this out: 34 | 35 | 36 | 37 | 38 |
39 | 40 | Now, what this class does, is it creates two comment nodes out of this one that you put there: 41 | 42 | 43 | 44 | 45 |
46 | 47 | ...and then it manages to put various element inbetween those two nodes. It looks as if 48 | they are wrapped, but the browser simply ignores the wrapper nodes, because they are comments. 49 | And comments are allowed anywhere. So instances of this class simply represent these virtual 50 | wrappers, or containers. 51 | 52 | */ 53 | class DOMVirtualContainer { 54 | 55 | /*********************************************************************************************/ 56 | /* Static methods */ 57 | /*********************************************************************************************/ 58 | 59 | static Node findNodesByName(String regexp_as_str, Node container, { skip_children: null }) { 60 | 61 | var result = []; 62 | var regexp = new RegExp(r'^' '$regexp_as_str' r'$'); 63 | 64 | if(container.childNodes.isEmpty) return result; 65 | 66 | container.childNodes.forEach((n) { 67 | 68 | if(n.nodeType == 8 && regexp.hasMatch(n.nodeValue)) { result.add(n); } 69 | 70 | else if(n.nodeType == 1) { 71 | 72 | /* skip_children List consists of two elements: 73 | skip_children[0] is the name of the attribute; skip_children[1] is the value of the attribute; 74 | If the value is not specified, it is assumed it can be anything. And so the purpose of this, 75 | is that we DO NOT LOOK INSIDE the elements that matche this condition. For example, if 76 | skip_attributes == ['data-dont-look-inside-me'] then we will ignore all child nodes 77 | with attributes data-dont-look-inside-me. If skip_attributes == ['data-dont-look-inside-me-if', 'true'], 78 | then we will only ignore elements like
but not 79 |
. 80 | */ 81 | if( 82 | skip_children == null || 83 | (skip_children.length > 1 && n.attributes[skip_children[0]] != skip_children[1]) || 84 | (skip_children.length == 1 && n.attributes[skip_children[0]] == null ) 85 | ) 86 | DOMVirtualContainer.findNodesByName(regexp_as_str, n).forEach((n) => result.add(n)); 87 | } 88 | 89 | }); 90 | 91 | return result; 92 | } 93 | 94 | 95 | /*********************************************************************************************/ 96 | /* Instance variables */ 97 | /*********************************************************************************************/ 98 | 99 | Node _opening; 100 | Node _closing; 101 | String name ; 102 | 103 | /*********************************************************************************************/ 104 | /* Constructors */ 105 | /*********************************************************************************************/ 106 | 107 | DOMVirtualContainer(Node node) { 108 | 109 | var next_node = node.nextNode; 110 | _opening = node; 111 | while(next_node != null) { 112 | if(next_node.nodeValue == "END-OF-${node.nodeValue}") { 113 | _closing = next_node; 114 | break; 115 | } 116 | next_node = next_node.nextNode; 117 | } 118 | 119 | if(_closing == null) { 120 | _opening = new DocumentFragment.html("").childNodes[0]; 121 | _closing = new DocumentFragment.html("").childNodes[0]; 122 | node.parent.insertBefore(_opening, node); 123 | node.parent.insertBefore(_closing, node); 124 | node.remove(); 125 | } 126 | 127 | this.name = _opening.nodeValue; 128 | 129 | } 130 | 131 | factory DOMVirtualContainer.find(String regexp, Element container, { skip_children: null }) { 132 | var comment_nodes = DOMVirtualContainer.findNodesByName(regexp, container, skip_children: skip_children); 133 | if(comment_nodes.isEmpty || comment_nodes.first == null) return; 134 | else return new DOMVirtualContainer(comment_nodes.first); 135 | } 136 | 137 | factory DOMVirtualContainer.findAll(String regexp, Element container, { skip_children: null }) { 138 | var comment_nodes = DOMVirtualContainer.findNodesByName(regexp, container, skip_children: skip_children); 139 | if(comment_nodes.isEmpty) return []; 140 | 141 | var invisible_containers = []; 142 | comment_nodes.forEach((n) { 143 | invisible_containers.add(new DOMVirtualContainer(n)); 144 | }); 145 | return invisible_containers; 146 | } 147 | 148 | /*********************************************************************************************/ 149 | /* Getters and setters */ 150 | /*********************************************************************************************/ 151 | get children { 152 | var _children = []; 153 | var next_node = _opening.nextNode; 154 | while (next_node != _closing) { 155 | _children.add(next_node); 156 | next_node = next_node.nextNode; 157 | } 158 | return _children; 159 | } 160 | 161 | get text => _opening.nextNode.text; 162 | set text(v) { 163 | children.forEach((c) => c.remove()); 164 | append(new Text(v)); 165 | } 166 | 167 | /*********************************************************************************************/ 168 | /* Public Methods */ 169 | /*********************************************************************************************/ 170 | 171 | prepend(Node el) { 172 | _opening.parent.insertBefore(el, _opening.nextNode); 173 | } 174 | 175 | append(Node el) { 176 | _closing.parent.insertBefore(el, _closing); 177 | } 178 | 179 | } 180 | -------------------------------------------------------------------------------- /test/component_temple_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:html'; 2 | import 'dart:mirrors'; 3 | import 'package:unittest/unittest.dart'; 4 | import 'package:unittest/html_config.dart'; 5 | import '../lib/component_temple.dart'; 6 | 7 | import 'package:attributable/attributable.dart'; 8 | import 'package:observable_roles/observable_roles.dart'; 9 | import 'package:validatable/validatable.dart'; 10 | 11 | class ButtonComponent extends Component { 12 | 13 | ButtonComponent(template, element) : super(template, element); 14 | ButtonComponent.build(Map properties) : super.build(properties); 15 | 16 | } 17 | 18 | class ValidatableComponent extends Component with Validatable { 19 | 20 | ValidatableComponent(template, element) : super(template, element); 21 | ValidatableComponent.build(Map properties) : super.build(properties); 22 | 23 | final List attribute_names = ['attr1', 'attr2']; 24 | 25 | final Map validations = { 26 | 'attr1' : { 'isLessThan' : 10 }, 27 | 'attr2' : { 'isLongerThan' : 5 } 28 | }; 29 | 30 | } 31 | 32 | class CaptionAsContentButtonComponent extends Component { 33 | 34 | CaptionAsContentButtonComponent(template, element) : super(template, element); 35 | 36 | final Map attribute_callbacks = { 37 | 'default' : (attr_name, self) => self.updateProperty(attr_name), 38 | 'caption' : (attr_name, self) => self.content.text = self.getAttributeAsString(attr_name) 39 | }; 40 | 41 | } 42 | 43 | class MockComponent extends Component { 44 | 45 | final Map internal_event_handlers = { 46 | '.clickableStuff' : { 'click' : 'increment_counter' } 47 | }; 48 | 49 | final Map event_handlers = { 50 | 'click' : null, 51 | 'increment_counter' : null 52 | }; 53 | 54 | List mock_calls = []; 55 | MockComponent(template, element) : super(template, element); 56 | 57 | @override captureEvent(event, [Publisher p]) { 58 | mock_calls.add("called captureEvent() with $event"); 59 | super.captureEvent(event, p); 60 | } 61 | 62 | } 63 | 64 | class DummyModel extends Object with Attributable, Publisher { 65 | 66 | final List attribute_names = ['caption']; 67 | 68 | get caption => attributes['caption'] ; 69 | set caption(v) => attributes['caption'] = v; 70 | 71 | } 72 | 73 | 74 | main() { 75 | 76 | useHtmlConfiguration(); 77 | 78 | TemplateStack.collect(document); 79 | TemplateStack.buildComponents(document.querySelector('body')); 80 | 81 | group('TemplateStack', () { 82 | 83 | test('creates instances of Template for all found templates', () { 84 | expect(TemplateStack.templates.length, equals(8)); 85 | TemplateStack.templates.forEach((k,v) => expect((v is Template), isTrue)); 86 | }); 87 | 88 | test('assigns child components to parents', () { 89 | var nested_button = TemplateStack.templates['button'].components['Button_4']; 90 | var button_container = TemplateStack.templates['buttonContainer'].components['ButtonContainer_1']; 91 | expect(nested_button.parent, equals(button_container)); 92 | expect(button_container.children().length, equals(2) ); 93 | expect(button_container.children()[0], equals(nested_button) ); 94 | }); 95 | 96 | }); 97 | 98 | group('Template', () { 99 | 100 | test('creates components out of itself from appropriate subclassess of Component', () { 101 | 102 | /* Note that here we're checking for 4 buttons and 1 greenButton, 103 | but 5 div.button elements. Look inside runner.html! I'm checking that 104 | a template by the name greenButton still uses ButtonComponent class because 105 | its attribute `component-class-name` is set to 'button'. 106 | */ 107 | expect(TemplateStack.templates['button'].components.length, equals(5)); 108 | expect(TemplateStack.templates['greenButton'].components.length, equals(1)); 109 | expect(document.querySelectorAll('div.button').length, equals(6)); 110 | 111 | TemplateStack.templates['button'].components.forEach((k,v) { 112 | ClassMirror component_class = reflect(v).type; 113 | expect(component_class.simpleName, equals(#ButtonComponent)); 114 | }); 115 | 116 | }); 117 | 118 | test('bases components numeration on hashed (time + random) nuber rather than integers if configured', () { 119 | expect( 120 | new RegExp('AltNumeration_..+').hasMatch(TemplateStack.templates['altNumeration'].components.values.first.name), 121 | isTrue 122 | ); 123 | }); 124 | 125 | }); 126 | 127 | group('Component', () { 128 | 129 | test('sets component id as a data- attribute to the component\'s html element', () { 130 | expect(document.querySelectorAll('.captionButton')[0].dataset['componentName'], equals('CaptionButton_1')); 131 | }); 132 | 133 | test('replaces content virtual container inside the template html for the contents of its custom element', () { 134 | expect(document.querySelectorAll('div.button')[0].text, equals('This is button 1 caption')); 135 | }); 136 | 137 | test('puts property values into appropriate virtual containers', () { 138 | var property_container = new DOMVirtualContainer.find('Component_property:caption', document.querySelector('.captionButton')); 139 | expect(property_container.text, equals('hello')); 140 | }); 141 | 142 | test('takes values for component properties from custom element data-property- attributes', () { 143 | expect(TemplateStack.templates['captionButton'].components['CaptionButton_1'].caption, equals('hello')); 144 | }); 145 | 146 | test('builds nested components using appropriate templates', () { 147 | expect(document.querySelectorAll('.buttonContainer .button').length, equals(2)); 148 | }); 149 | 150 | test('changes corresponding element contents in DOM when a field changes', () { 151 | var property_container = new DOMVirtualContainer.find('Component_property:caption', document.querySelector('.captionButton')); 152 | TemplateStack.templates['captionButton'].components['CaptionButton_1'].caption = 'new caption'; 153 | expect(property_container.text, equals('new caption')); 154 | }); 155 | 156 | test('notifies parent of the event', () { 157 | 158 | // 1. Trigger a click on a dom element 159 | document.querySelectorAll('.mock .mock')[0].click(); 160 | 161 | // 2. Make sure the component caught the event 162 | expect(TemplateStack.templates['mock'].components['Mock_2'].mock_calls[0], equals('called captureEvent() with click')); 163 | 164 | // 3. Make sure the parent of the component was also notified of the event 165 | expect(TemplateStack.templates['mock'].components['Mock_1'].mock_calls[0], equals('called captureEvent() with any.click')); 166 | 167 | }); 168 | 169 | test('handles events of DOM elements which the component is composed of', () { 170 | document.querySelectorAll('.mock .mock .clickableStuff')[0].click(); 171 | expect(TemplateStack.templates['mock'].components['Mock_2'].mock_calls.contains('called captureEvent() with increment_counter'), equals(true)); 172 | expect(TemplateStack.templates['mock'].components['Mock_1'].mock_calls.contains('called captureEvent() with any.increment_counter'), equals(true)); 173 | }); 174 | 175 | test('gets assigned a role', () { 176 | expect(TemplateStack.templates['button'].components['Button_1'].role, equals('stupid')); 177 | }); 178 | 179 | test('puts newly added childrens\' DOM elements into containers based on their role', () { 180 | var button_container = TemplateStack.templates['buttonContainer'].components['ButtonContainer_1']; 181 | var button_without_role = new ButtonComponent.build({}); 182 | var button_with_role = new ButtonComponent.build({ 'role': 'submit'}); 183 | button_container.addChild(button_without_role); 184 | button_container.addChild(button_with_role); 185 | var any_children = new DOMVirtualContainer.find('Component_children:any', button_container.element); 186 | var submit_children = new DOMVirtualContainer.find('Component_children:submit', button_container.element); 187 | expect(any_children.children[0], equals(button_without_role.element)); 188 | expect(submit_children.children[0], equals(button_with_role.element)); 189 | }); 190 | 191 | test('executes a function when adding a new child', () { 192 | var button_container = TemplateStack.templates['buttonContainer'].components['ButtonContainer_1']; 193 | var new_button = new ButtonComponent.build({}); 194 | var function_called = false; 195 | button_container.addChild(new_button, (c) { 196 | function_called = true; 197 | }); 198 | expect(function_called, isTrue); 199 | }); 200 | 201 | test('assigns a custom name for the component, does not allow to change it later', () { 202 | var super_button = TemplateStack.templates['button'].components['SuperButton_1']; 203 | expect(super_button, isNotNull); 204 | expect(() => super_button.name = "NewSuperButtonName_1", throwsException); 205 | }); 206 | 207 | test('removes itself from DOM and from the Template\'s components Map', () { 208 | var button_container = TemplateStack.templates['buttonContainer'].components['ButtonContainer_1']; 209 | var button_components = TemplateStack.templates['button'].components; 210 | var new_button = new ButtonComponent.build({ 'name' : 'ButtonToBeRemovedLater_1' }); 211 | button_container.addChild(new_button); 212 | expect(button_components['ButtonToBeRemovedLater_1'], isNotNull); 213 | expect(document.querySelector('.button[data-component-name=ButtonToBeRemovedLater_1]'), isNotNull); 214 | new_button.remove(); 215 | expect(button_components['ButtonToBeRemovedLater_1'], isNull); 216 | expect(document.querySelector('.button[data-component-name=ButtonToBeRemovedLater_1]'), isNull); 217 | }); 218 | 219 | test('updates content when binded attribute is updated', () { 220 | // This actually checks the concept, not the core functionality of the Component class. 221 | // The behavior responsible for this test passing is defined above in this file 222 | // in a CaptionAsContentButtonComponent class. 223 | var component = TemplateStack.templates['captionAsContentButton'].components['CaptionAsContentButton_1']; 224 | var content = new DOMVirtualContainer.find('Component_content', component.element); 225 | component.caption = "new content"; 226 | expect(content.text, equals('new content')); 227 | }); 228 | 229 | 230 | group('adding and removing children with roles', () { 231 | 232 | var button_container = TemplateStack.templates['buttonContainer'].components['ButtonContainer_1']; 233 | button_container.addChild(new ButtonComponent.build({ 'role': 'temporary_button'})); 234 | 235 | test('finds children with specific roles', () { 236 | expect(button_container.children('temporary_button').length, equals(1)); 237 | expect((button_container.children().length > 1), isTrue); 238 | expect((button_container.children('temporary_button')[0] is Component), isTrue); 239 | }); 240 | 241 | test("when removed, removes itself from the parent's, children list", () { 242 | var button = new ButtonComponent.build({ 'role': 'temporary_button' }); 243 | button_container.addChild(button); 244 | expect(button_container.children('temporary_button').contains(button), isTrue); 245 | expect(button_container.children().contains(button), isTrue); 246 | button.remove(); 247 | expect(button_container.children('temporary_button').contains(button), isFalse); 248 | expect(button_container.children().contains(button), isFalse); 249 | }); 250 | 251 | }); 252 | 253 | test('updates all attributes when subscribing component to the model and then when updating the model', () { 254 | var button = new ButtonComponent.build({ 'template_name': 'captionButton' }); 255 | expect(button.caption, isNull); 256 | var model = new DummyModel(); 257 | model.caption = 'caption change 1'; 258 | button.model = model; 259 | expect(button.caption, equals('caption change 1')); 260 | expect(new DOMVirtualContainer.find('Component_property:caption', button.element).text, equals('caption change 1')); 261 | model.caption = 'caption change 2'; 262 | button.captureEvent('model.update', model); 263 | expect(button.caption, equals('caption change 2')); 264 | expect(new DOMVirtualContainer.find('Component_property:caption', button.element).text, equals('caption change 2')); 265 | }); 266 | 267 | test('updates attributes in bulk, adds validation errors (Validatable is mixed in)', () { 268 | var button = new ButtonComponent.build({}); 269 | button.updateAttributes({ 'caption' : 'new caption'}); 270 | expect(button.caption, equals('new caption')); 271 | var validatable_component = new ValidatableComponent.build({ 'template_name': 'button'}); 272 | expect(validatable_component.updateAttributes({ 'attr1' : 11, 'attr2': ''}), isFalse); 273 | expect(validatable_component.validation_errors.keys.contains('attr1'), isTrue); 274 | expect(validatable_component.validation_errors.keys.contains('attr2'), isTrue); 275 | }); 276 | 277 | }); 278 | 279 | group('string case operations', () { 280 | test('converts dasherized names int camelcase', () { 281 | expect(dashedToCamelcase('hello-world'), equals('HelloWorld')); 282 | }); 283 | }); 284 | 285 | test("finds all subclasses", () { 286 | expect(findSubclasses('Component').length, equals(3)); 287 | expect(findSubclasses('Component')[1], equals(reflectClass(ButtonComponent))); 288 | }); 289 | 290 | } 291 | -------------------------------------------------------------------------------- /lib/component.dart: -------------------------------------------------------------------------------- 1 | part of component_temple; 2 | 3 | class Component extends Object with observable.Subscriber, observable.Publisher, Attributable { 4 | 5 | static NodeValidatorBuilder childNodeValidator(tag_name) { 6 | return new NodeValidatorBuilder() 7 | ..allowHtml5() 8 | ..allowElement(tag_name); 9 | } 10 | 11 | /*********************************************************************************************/ 12 | /* Instance variables declarations */ 13 | /*********************************************************************************************/ 14 | 15 | Element element ; // DOM element which corresponds to the current object 16 | Element content ; // DOM element which holds the {{content}} of the element 17 | Template template ; // our Template object we're going to use to generate an element 18 | Component _parent ; // another Component which is a parent of the current one (could be null) 19 | String _name ; // a combination of unique id and a name of the component, for instance Button_1 20 | // could also be set by user and be whatever. 21 | 22 | /* Roles determine what a parent does with events from components. 23 | A role may be defined in html by adding `data-component-role` attribute. 24 | */ 25 | String role = 'any'; 26 | 27 | Map virtual_containers = {}; 28 | 29 | var _model; // We don't really want to specify class name here and require TempleModel library; 30 | // It's better to have more freedom. 31 | 32 | /* Each value of this map holds a child with a specific role, while keys are role names. 33 | It is not supposed to be accessed directly (but it has to be public, cos for some reason 34 | subclasses in Dart don't recognize superclass instance vars that start with _. 35 | Anyway, a proper way to access children is by using a children() getter - note how () at the end 36 | are obligatory. 37 | */ 38 | Map children_by_roles = {}; 39 | 40 | /* This is how you list attributes for your component. They are not real properties. Instead, 41 | getters and setters are dynamic (caught by noSuchMethod). This is helpful because 42 | we want to be able to execute callbacks on attribute change. 43 | */ 44 | final List attribute_names = ['caption']; 45 | Map attributes = {}; 46 | 47 | /* When an attribute is updated, these are the callbacks that get invoked. 48 | There's only one default one and it updates the corresponding DOM element with the 49 | new value. You can either redefine it or add your own callbacks for each 50 | particular attribute. 51 | 52 | `self_mirror` argument is a mirror of the current object. We can't simply pass 53 | `this`, because this raises an exception. 54 | */ 55 | final Map attribute_callbacks = { 56 | 'default' : (attr_name, self) => self.updateProperty(attr_name) 57 | }; 58 | 59 | /* This is where events are defined. 60 | If you don't want this Element do anything on the event, but still want its parent 61 | to be notified, use null as a value for the key (a key represents an event name). 62 | */ 63 | final Map event_handlers = { 64 | 65 | 'click' : null, // Still notify the parent of the click event. 66 | 67 | 'model.update' : (self, model) { 68 | self.attribute_names.forEach((attr_name) { 69 | if(model.attribute_names.contains(attr_name)) 70 | self.attributes[attr_name] = model.attributes[attr_name]; 71 | }); 72 | self.updateElement(); 73 | } 74 | 75 | }; 76 | 77 | /* Components may consist of several DOM elemnts, and each may be able to trigger an event. 78 | This event is caught here and then we can decide what custom event in our component is 79 | then called. 80 | 81 | They key of the map is a selector used to find a an element withing the component with 82 | querySelectorAll(). The value is another Map: they key is name of the DOM event, the value 83 | is the name of the Component event that's going to be called. 84 | */ 85 | final Map internal_event_handlers = {}; 86 | 87 | /* There's no reason to save those Streams yet, but we may later need to be able 88 | to cancel event listeners, so let's do it. 89 | */ 90 | Map event_listeners = {}; 91 | 92 | /* Put things like making a Button inactive or animation in here. 93 | Basic Component is dumb, cannot behave itself. 94 | */ 95 | final Map behaviors = {}; 96 | 97 | 98 | /*********************************************************************************************/ 99 | /* Constructors */ 100 | /*********************************************************************************************/ 101 | 102 | /* Used by Template objects only */ 103 | Component(this.template, this.element) { 104 | _shared_constructor(); 105 | } 106 | 107 | /* Used to create components dynamically from code */ 108 | Component.build(Map properties) { 109 | 110 | if(properties['template_name'] == null) { 111 | properties['template_name'] = downcaseFirstLetter(MirrorSystem.getName(reflect(this).type.simpleName).replaceFirst('Component', '')); 112 | } 113 | 114 | if(properties['role'] == null) 115 | properties['role'] = 'any'; 116 | 117 | this.role = properties['role']; 118 | this.template = TemplateStack.templates[properties['template_name']]; 119 | this.element = new Element.html("<${this.template.name}>", validator: Component.childNodeValidator(properties['template_name'])); 120 | this._name = properties['name']; 121 | _shared_constructor(); 122 | this.template.addComponent(this); 123 | } 124 | 125 | _shared_constructor() { 126 | 127 | _setDefaultProperties(); 128 | 129 | if(this.name == null) { 130 | if(this.element.dataset['componentName'] != null) { 131 | this._name = this.element.dataset['componentName']; 132 | } else { 133 | this._name = "${this.template.name}_${this.template.next_component_number()}"; 134 | } 135 | } 136 | 137 | if(this.element.dataset['componentRole'] != null) { 138 | this.role = this.element.dataset['componentRole']; 139 | } 140 | 141 | var template_element = this.template.buildDomElement(); 142 | this.content = new DOMVirtualContainer.find('Component_content', template_element, skip_children: ['data-component-name']); 143 | if(this.content != null) { 144 | this.element.childNodes.forEach((n) { 145 | this.content.append(n.clone(true)); 146 | }); 147 | } 148 | 149 | prvt_replaceDomElementWithTemplate(template_element); 150 | updateElement(); 151 | _setDOMEventListeners(); 152 | 153 | } 154 | 155 | /*********************************************************************************************/ 156 | /* Custom getters and setters */ 157 | /*********************************************************************************************/ 158 | 159 | get parent => _parent; 160 | set parent(Component p) { 161 | setParent(p); 162 | if(parent.children_by_roles[this.role] == null) { parent.children_by_roles[this.role] = []; } 163 | parent.children_by_roles[this.role].add(this); 164 | } 165 | 166 | /* This is for cases where we want to set a parent, but then manually add 167 | a child to the children's list. 168 | */ 169 | setParent(Component p) { 170 | _parent = p; 171 | // Whenever we add a parent, it becomes a subscriber to all the events happening in this view. 172 | addObservingSubscriber(p); 173 | } 174 | 175 | get name => _name; 176 | set name(String new_name) { 177 | // Probably later this should be allowed 178 | // but for now it's too much work: change DOM element data attr, 179 | // change Template's component Map name etc. 180 | throw new Exception("You cannot change component's name after it was already built."); 181 | } 182 | 183 | /* Subscribes this Component to the model's events and immediately 184 | invokes 'model.update' callback to sync with model attributes 185 | */ 186 | get model => _model; 187 | set model(m) { 188 | 189 | if(!(m is observable.Publisher) || !(m is Attributable)) 190 | throw new Exception("Model must implement observable.Publisher and Attributable interfaces!"); 191 | 192 | m.addObservingSubscriber(reflect(this).reflectee); 193 | captureEvent('model.update', m) ; 194 | _model = m; 195 | 196 | } 197 | 198 | /* Returns children of specific roles or all children. 199 | Always use () when calling this getter, because it's not actually a getter, 200 | but philosophically it is. 201 | */ 202 | children([role]) { 203 | if(role == null || role == 'any') { 204 | var result = []; 205 | children_by_roles.forEach((k,v) { 206 | result.addAll(v); 207 | }); 208 | return result; 209 | } else { 210 | return children_by_roles[role]; 211 | } 212 | } 213 | 214 | /*********************************************************************************************/ 215 | /* Public Methods */ 216 | /*********************************************************************************************/ 217 | 218 | addChild(Component child, [Function f]) { 219 | child.parent = this; 220 | if(f != null) { 221 | f(child); 222 | } else { 223 | addChildToDOM(child); 224 | } 225 | } 226 | 227 | addChildToDOM(Component child) { 228 | var children_container = _getFirstVirtualContainer("children:${child.role}"); 229 | if(children_container != null) { children_container.append(child.element); } 230 | } 231 | 232 | remove() { 233 | this.children().forEach((c) => c.remove()); // remove children before removing itself! 234 | if(this.parent != null) 235 | this.parent.prvt_removeChild(reflect(this).reflectee); 236 | this.template.components.remove(this.name); 237 | this.element.remove(); 238 | this.element = null; 239 | if(model != null) { 240 | model.removeObservingSubscriber(this); 241 | model = null; 242 | } 243 | } 244 | 245 | /* Updates the property container of a DOM element */ 246 | updateProperty(property_name, [value=null]) { 247 | 248 | if(value == null) 249 | value = getAttributeAsString(property_name); 250 | 251 | var property_containers = _getVirtualContainers('property:${property_name}'); 252 | property_containers.forEach((c) => c.text = value); 253 | 254 | } 255 | 256 | /* Calls for attributes callbacks for each attribute and, supposedly, 257 | updates property containers in a DOM element. Of course, that is the default 258 | behavior when an attribute is updated, but it can be changed if another 259 | callback is assigned. 260 | */ 261 | updateElement() { 262 | attribute_names.forEach((attr_name) { 263 | invokeAttributeCallback(attr_name); 264 | }); 265 | } 266 | 267 | getAttributeAsString(attr_name) { 268 | var value = attributes[attr_name]; 269 | if(value == null) { value = ''; } 270 | else if(!(value is String)) { value = value.toString(); } 271 | return value; 272 | } 273 | 274 | updateAttributes(attrs) { 275 | return super.updateAttributes(attrs, () { 276 | try { 277 | if(this is Validatable) { 278 | validate(); 279 | if(valid) 280 | return true; 281 | else 282 | return false; 283 | } 284 | } on TypeError { 285 | return true; 286 | // Do nothing, Validatable is not loaded, who gives a shit! 287 | } 288 | }); 289 | } 290 | 291 | behave(b) { 292 | if(this.behaviors[b] != null) { 293 | this.behaviors[b](this.element); 294 | } else { 295 | throw new Exception("No behavior `${b}` defined for ${this.template.componentClassName}"); 296 | } 297 | } 298 | 299 | /*********************************************************************************************/ 300 | /* Private Methods */ 301 | /*********************************************************************************************/ 302 | 303 | /* Extracts property values from component's custom html element attributes and assigns them to 304 | to properties of the object. Can only be called from constructor, since the element 305 | gets replaced. 306 | */ 307 | _setDefaultProperties() { 308 | 309 | this.element.dataset.forEach((k,v) { 310 | var property_name = downcaseFirstLetter(k.replaceAll('property', '')); 311 | if(attribute_names.contains(property_name)) { 312 | attributes[property_name] = v; 313 | } 314 | }); 315 | 316 | } 317 | 318 | _propertyElements([property_name=null]) { 319 | if(property_name == null) { 320 | return _getVirtualContainers("property.*"); 321 | } else { 322 | return _getVirtualContainers("property:${property_name}"); 323 | } 324 | } 325 | 326 | /* Do not use this method anywhere. It should only be used by remove() method of the child. 327 | That's because a child is only removed from the children_by_roles list, but not from the DOM. 328 | */ 329 | prvt_removeChild(Component child) { 330 | if(children_by_roles[child.role] != null) 331 | children_by_roles[child.role].remove(child); 332 | } 333 | 334 | /* This only called from constructor and is for cases when we want to redefine 335 | it in subclasses. It may sometimes be required. Like for example when it is impossible 336 | to create a template element whose root element is . Instead, such an element must be wrapped 337 | in , then in a subclass which handles the template this would extracted from 338 | the
and insrted into the DOM - this would happen in this redefined method. 339 | */ 340 | prvt_replaceDomElementWithTemplate(template_element) { 341 | template_element.dataset['componentName'] = this.name; 342 | this.element.replaceWith(template_element); // Replacing element in the DOM 343 | this.element = template_element ; // Replacing element in component instance 344 | } 345 | 346 | _getVirtualContainers(container_name) { 347 | if(virtual_containers[container_name] == null) 348 | virtual_containers[container_name] = new DOMVirtualContainer.findAll('Component_${container_name}', this.element, skip_children: ['data-component-name']); 349 | return virtual_containers[container_name]; 350 | } 351 | 352 | _getFirstVirtualContainer(container_name) { 353 | return new DOMVirtualContainer.find('Component_${container_name}', this.element, skip_children: ['data-component-name']); 354 | } 355 | 356 | _setDOMEventListeners() { 357 | 358 | event_handlers.forEach((k,v) { 359 | // Only set those listeners whose event names don't have a dot in them, 360 | // which indicates those are reserved for children. 361 | if(!k.contains('.')) { 362 | event_listeners[k] = element.on[k].listen((event) { 363 | event.stopPropagation(); 364 | captureEvent(k); 365 | }); 366 | } 367 | 368 | internal_event_handlers.forEach((el_query, el_events) { 369 | element.querySelectorAll("${el_query}").forEach((el) { 370 | el_events.forEach((e,h) { 371 | el.on[e].listen((event) { 372 | event.stopPropagation(); 373 | captureEvent(h); 374 | }); 375 | }); 376 | }); 377 | }); 378 | 379 | }); 380 | } 381 | 382 | 383 | /*********************************************************************************************/ 384 | /* Everything else */ 385 | /*********************************************************************************************/ 386 | 387 | noSuchMethod(Invocation i) { 388 | var result = prvt_noSuchGetterOrSetter(i); 389 | if(result != false) 390 | return result; 391 | else 392 | super.noSuchMethod(i); 393 | } 394 | 395 | } 396 | --------------------------------------------------------------------------------