Step 10: Re-usable Template Macros

In Step 05: Making a Main Template we made a main template that was used for all screens in the site. We used ZPT machinery as the solution.

Sometimes, though, it isn’t a global look and feel that needs re-use. It is a little snippet that is common to some of the pages, but not all. Or perhaps, a block that looks different on some screens versus others.

ZPT macros are the solution here.

Goals

  • Share small snippets of code between view templates

Objectives

  • Make a ZPT template file with some re-usable macros
  • Associate that template file with the layouts
  • Provide any logic needed in the snippet

Steps

  1. $ cd ../../creatingux; mkdir step10; cd step10

  2. Copy the following into step10/application.py:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    from wsgiref.simple_server import make_server
    
    from pyramid.config import Configurator
    
    def main():
        config = Configurator()
        config.scan("views")
        config.add_static_view('static', 'static/',
                               cache_max_age=86400)
        app = config.make_wsgi_app()
        return app
    
    if __name__ == '__main__':
        app = main()
        server = make_server('0.0.0.0', 8080, app)
        server.serve_forever()
    
  3. $ mkdir static

  4. Copy the following into step10/static/global_layout.css:

     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
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    body {
        font-family: Arial, sans-serif;
        font-size: 1em;
        padding: 0;
        margin: 0;
        background-color: white;
    }
    
    #header {
        height: 3em;
        background-color: lightgray;
    }
    
    #header ul, #company_menu {
        margin-left: 1em;
        padding: 0;
    }
    
    #header li, #company_menu li {
        display: inline-block;
        margin-top: 1em;
        padding-right: 0.8em;
    }
    
    #header a {
        color: black;
    }
    #main {
        margin: 2em;
    }
    
    #sidebar {
        float:right;
        width: 300px;
        border-left: solid 2px gray;
        border-bottom:  solid 2px gray;
        padding-left: 10px;
    }
    
    #sidebar ul {
        height: 100px;
    }
    #sidebar h2 {
        text-align: center;
    }
    
    #sidebar p {
        text-align: center;
    }
    
  5. Copy the following into step10/static/global_layout.js:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    $(function() {
    
        function get_updates () {
            $.getJSON('/updates.json', function(data) {
                var target = $('#sidebar ul');
                target.empty();
                $.each(data, function (key, val) {
                    target.append('<li>Update #' + val + '</li>');
                });
            });
        }
    
        $('#sidebar a').click(function () {
            get_updates();
        });
    
        get_updates();
    });
    
  6. Copy the following into step10/views.py:

     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
    34
    35
    36
    37
    38
    39
    40
    41
    42
    from random import randint
    
    from pyramid.view import view_config
    
    from dummy_data import COMPANY
    from dummy_data import PEOPLE
    from dummy_data import PROJECTS
    
    from layouts import Layouts
    
    class ProjectorViews(Layouts):
    
        def __init__(self, request):
            self.request = request
    
        @view_config(renderer="templates/index.pt")
        def index_view(self):
            return {"page_title": "Home"}
    
        @view_config(renderer="templates/about.pt", name="about.html")
        def about_view(self):
            return {"page_title": "About"}
    
        @view_config(renderer="templates/company.pt",
                     name="acme")
        def company_view(self):
            return {"page_title": COMPANY + " Projects",
                    "projects": PROJECTS}
    
        @view_config(renderer="templates/people.pt", name="people")
        def people_view(self):
            return {"page_title": "People", "people": PEOPLE}
    
        @view_config(renderer="json", name="updates.json")
        def updates_view(self):
            return [
                randint(0,100),
                randint(0,100),
                randint(0,100),
                randint(0,100),
                888,
            ]
    
  7. Copy the following into step10/layouts.py:

     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
    from pyramid.renderers import get_renderer
    from pyramid.decorator import reify
    
    from dummy_data import COMPANY
    from dummy_data import SITE_MENU
    
    class Layouts(object):
    
        @reify
        def global_template(self):
            renderer = get_renderer("templates/global_layout.pt")
            return renderer.implementation().macros['layout']
    
        @reify
        def company_name(self):
            return COMPANY
    
        @reify
        def global_macros(self):
            renderer = get_renderer("templates/macros.pt")
            return renderer.implementation().macros
    
        @reify
        def site_menu(self):
            new_menu = SITE_MENU[:]
            url = self.request.url
            for menu in new_menu:
                if menu['title'] == 'Home':
                    menu['current'] = url.endswith('/')
                else:
                    menu['current'] = url.endswith(menu['href'])
            return new_menu
    
  8. Copy the following into step10/dummy_data.py:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    # Dummy data
    COMPANY = "ACME, Inc."
    
    PEOPLE = [
            {'name': 'sstanton', 'title': 'Susan Stanton'},
            {'name': 'bbarker', 'title': 'Bob Barker'},
    ]
    
    PROJECTS = [
            {'name': 'sillyslogans', 'title': 'Silly Slogans'},
            {'name': 'meaninglessmissions', 'title': 'Meaningless Missions'},
    ]
    
    SITE_MENU = [
            {'href': '', 'title': 'Home'},
            {'href': 'about.html', 'title': 'About Projector'},
            {'href': 'acme', 'title': COMPANY},
            {'href': 'people', 'title': 'People'},
    ]
    
  9. Copy the following “global template” into step10/templates/global_layout.pt:

     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
    34
    35
    36
    37
    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
            "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
    <html xmlns="http://www.w3.org/1999/xhtml"
          xmlns:metal="http://xml.zope.org/namespaces/metal"
          xmlns:tal="http://xml.zope.org/namespaces/tal"
          metal:define-macro="layout">
    <head>
        <title>Projector - ${page_title}</title>
        <link rel="stylesheet" href="/static/global_layout.css"/>
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.4/jquery.min.js"></script>
        <script src="/static/global_layout.js"></script>
    </head>
    <body>
    <div id="header">
        <ul>
            <li tal:repeat="menu view.site_menu">
                <tal:block tal:condition="menu.current">
                    <span>${menu.title}</span>
                </tal:block>
                <tal:block tal:condition="not menu.current">
                    <span><a href="/${menu.href}">${menu.title}</a></span>
                </tal:block>
            </li>
        </ul>
    </div>
    <div id="sidebar">
        <h2>Updates</h2>
        <ul></ul>
        <p><a href="#">reload</a></p>
    </div>
    <div id="main"><h1>${page_title}</h1>
    
        <div metal:define-slot="content">
        </div>
    </div>
    </body>
    </html>
    
  10. Copy the following “macros template” into step10/templates/macros.pt:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
            "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
    <html xmlns="http://www.w3.org/1999/xhtml"
          xmlns:metal="http://xml.zope.org/namespaces/metal"
          xmlns:tal="http://xml.zope.org/namespaces/tal">
    <head>
        <title>Macros</title>
    </head>
    <body>
    <metal:company_menu define-macro="company_menu">
        <ul id="company_menu">
            <li>Projects: </li>
            <li tal:repeat="project projects">
                <a href="${project.name}">${project.title}</a>
            </li>
        </ul>
    </metal:company_menu>
    </body>
    </html>
    
  11. Copy the following into step10/templates/index.pt:

    1
    2
    3
    4
    5
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <p>Home page content goes here.</p>
        </div>
    </div>
    
  12. Copy the following into step10/templates/about.pt:

    1
    2
    3
    4
    5
    6
    7
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <p>Projector is a simple project management tool capable of hosting
                multiple projects for multiple independent companies,
                sharing a developer pool between autonomous companies.</p>
        </div>
    </div>
    
  13. Copy the following into step10/templates/company.pt:

    1
    2
    3
    4
    5
    6
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <p>Welcome to our company project management system.</p>
            <div metal:use-macro="view.global_macros['company_menu']"></div>
        </div>
    </div>
    
  14. Copy the following into step10/templates/people.pt:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <ul>
                <li tal:repeat="person people">
                    <a href="${person.name}">${person.title}</a>
                </li>
            </ul>
        </div>
    </div>
    
  15. Copy the following into step10/test_views.py:

     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
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    import unittest
    
    from pyramid.testing import DummyRequest
    from pyramid.testing import setUp
    from pyramid.testing import tearDown
    
    class ProjectorViewsUnitTests(unittest.TestCase):
        def setUp(self):
            request = DummyRequest()
            self.config = setUp(request=request)
    
        def tearDown(self):
            tearDown()
    
        def _makeOne(self, request):
            from views import ProjectorViews
    
            inst = ProjectorViews(request)
            return inst
    
        def test_index_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.index_view()
            self.assertEqual(result['page_title'], 'Home')
    
        def test_about_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.about_view()
            self.assertEqual(result['page_title'], 'About')
    
        def test_company_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.company_view()
            self.assertEqual(result["page_title"], "ACME, Inc. Projects")
            self.assertEqual(len(result["projects"]), 2)
    
        def test_people_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.people_view()
            self.assertEqual(result["page_title"], "People")
            self.assertEqual(len(result["people"]), 2)
    
        def test_updates_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.updates_view()
            self.assertEqual(len(result), 5)
    
    class ProjectorFunctionalTests(unittest.TestCase):
        def setUp(self):
            from application import main
            app = main()
            from webtest import TestApp
            self.testapp = TestApp(app)
    
        def test_it(self):
            res = self.testapp.get('/', status=200)
            self.assertTrue(b'Home' in res.body)
            res = self.testapp.get('/about.html', status=200)
            self.assertTrue(b'autonomous' in res.body)
            res = self.testapp.get('/people', status=200)
            self.assertTrue(b'Susan' in res.body)
            res = self.testapp.get('/acme', status=200)
            self.assertTrue(b'Silly Slogans' in res.body)
            res = self.testapp.get('/updates.json', status=200)
            self.assertTrue(b'888' in res.body)
    
  16. Copy the following into step10/test_layout.py:

     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
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    import unittest
    
    from pyramid.testing import DummyRequest
    from pyramid.testing import setUp
    from pyramid.testing import tearDown
    
    class LayoutUnitTests(unittest.TestCase):
        def setUp(self):
            request = DummyRequest()
            self.config = setUp(request=request)
    
        def tearDown(self):
            tearDown()
    
        def _makeOne(self):
            from layouts import Layouts
    
            inst = Layouts()
            return inst
    
        def test_global_template(self):
            from chameleon.zpt.template import Macro
    
            inst = self._makeOne()
            self.assertEqual(inst.global_template.__class__, Macro)
    
        def test_global_macros(self):
            from chameleon.zpt.template import Macros
    
            inst = self._makeOne()
            self.assertEqual(inst.global_macros.__class__, Macros)
    
    
        def test_company_name(self):
            from dummy_data import COMPANY
    
            inst = self._makeOne()
            self.assertEqual(inst.company_name, COMPANY)
    
        def test_site_menu(self):
            from dummy_data import SITE_MENU
    
            inst = self._makeOne()
            inst.request = DummyRequest()
            self.assertEqual(len(inst.site_menu), len(SITE_MENU))
    
  17. $ nosetests should report running 10 tests.

  18. $ python application.py

  19. Open http://127.0.0.1:8080 in your browser and look at the ACME, Inc. link to see the projects menu.

Extra Credit

  1. Could you generate the macro from a string of HTML in the Python code?
  2. Have a conditional selection of which macros are used, where the choice is made on the Python side.
  3. Move the projects from being provided by each view, into the view class. Then, move it to the layout.

Analysis

Macros are a long-standing feature in the world of Zope, and as such, are a mature and well-understood way to decompose UX into re-usable snippets.

The indirections in Zope (2, 3, CMF, Plone), though, has made “Where did that pixel come from?” into a crazy adventure. But if you remove the pluggability, macros become a less mysterious and more useful tool.

Discussion

  • Where is the right place to put stuff like projects? That is, data that is needed in a macro. Each view, each view class, the “Template API” (aka layout)?
  • Are macros performant in Chameleon?
  • With all this decomposition, has the original idea of ZPT (the Dreamweaver person can co-own the artifact) been made inoperative?

Table Of Contents