From f2ccd859c1715f5e452676221f7a417b63ba47c8 Mon Sep 17 00:00:00 2001 From: Maurits van Rees Date: Mon, 31 Oct 2016 15:22:23 +0100 Subject: [PATCH] Added option to show action globally, regardless of blocked portlets. Added controlpanel and settings for this. The timeout is now always the time since the first visit of a page with this portlet. We save the start time in the cookie. If you leave the site before viewing the action, and you come back after more than an hour, the timeout is reset: we start counting fresh. --- CHANGES.rst | 4 +- README.rst | 10 +- src/collective/calltoaction/__init__.py | 1 + .../calltoaction/browser/configure.zcml | 7 + .../calltoaction/browser/controlpanel.py | 16 ++ .../calltoaction/browser/viewlet.py | 24 ++- src/collective/calltoaction/configure.zcml | 8 + src/collective/calltoaction/interfaces.py | 19 +++ .../profiles/default/controlpanel.xml | 20 +++ .../profiles/default/metadata.xml | 2 +- .../profiles/default/registry.xml | 3 + .../profiles/testfixture/portlets.xml | 7 + .../calltoaction/resources/calltoaction.js | 149 +++++++++++------- src/collective/calltoaction/testing.py | 10 +- .../calltoaction/tests/test_viewlet.py | 34 ++++ 15 files changed, 250 insertions(+), 64 deletions(-) create mode 100644 src/collective/calltoaction/browser/controlpanel.py create mode 100644 src/collective/calltoaction/profiles/default/controlpanel.xml create mode 100644 src/collective/calltoaction/profiles/default/registry.xml diff --git a/CHANGES.rst b/CHANGES.rst index b770825..c9f5e71 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,9 @@ Changelog 1.0rc2 (unreleased) ------------------- -- Nothing changed yet. +- Added option to show action globally, regardless of blocked portlets. + The timeout is now always the time since the first visit of a page with this portlet. + [maurits] 1.0rc1 (2016-04-20) diff --git a/README.rst b/README.rst index b99f696..cd6a088 100644 --- a/README.rst +++ b/README.rst @@ -18,7 +18,7 @@ or button. Compatibility ------------- -Works on Plone 4.3.x, tested explicitly on Plone 4.3.8. +Works on Plone 4.3.x, tested explicitly on Plone 4.3.8 and 4.3.11. Not yet compatible with Plone 5: the javascript and css need work. @@ -29,9 +29,10 @@ Features This is basically a copy of the static text portlet with a few extra options. - In the portlet you can set the number of milliseconds before the overlay is shown. + This can be several minutes and go over multiple pages: + using a cookie, we keep track of how long you have been on the site. -- When the overlay is shown, a cookie is set. - We use this to show the overlay only once. +- When the overlay is shown, the cookie is updated so that we show the overlay only once. The cookie is specific for this portlet: a new call to action portlet will be shown once too. @@ -47,6 +48,9 @@ Features but only one overlay is shown on a page. If there are three portlets, and the user has already seen the first one but not the others, then the second one will be shown. +- There is a control panel where you can say that the action is global across the site. + This can help if parts of your site block the portlets and you still want to see the action there. + Examples -------- diff --git a/src/collective/calltoaction/__init__.py b/src/collective/calltoaction/__init__.py index 3a1f1bc..e1884a8 100644 --- a/src/collective/calltoaction/__init__.py +++ b/src/collective/calltoaction/__init__.py @@ -3,4 +3,5 @@ from zope.i18nmessageid import MessageFactory + _ = MessageFactory('collective.calltoaction') diff --git a/src/collective/calltoaction/browser/configure.zcml b/src/collective/calltoaction/browser/configure.zcml index d3b5444..b80603e 100644 --- a/src/collective/calltoaction/browser/configure.zcml +++ b/src/collective/calltoaction/browser/configure.zcml @@ -14,4 +14,11 @@ permission="zope2.View" /> + + diff --git a/src/collective/calltoaction/browser/controlpanel.py b/src/collective/calltoaction/browser/controlpanel.py new file mode 100644 index 0000000..f863718 --- /dev/null +++ b/src/collective/calltoaction/browser/controlpanel.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from collective.calltoaction import _ +from collective.calltoaction.interfaces import ICollectiveCalltoactionSettings +from plone.app.registry.browser.controlpanel import ControlPanelFormWrapper +from plone.app.registry.browser.controlpanel import RegistryEditForm +from plone.z3cform import layout +from z3c.form import form + + +class CalltoactionControlPanelForm(RegistryEditForm): + form.extends(RegistryEditForm) + schema = ICollectiveCalltoactionSettings + +CalltoactionControlPanelView = layout.wrap_form( + CalltoactionControlPanelForm, ControlPanelFormWrapper) +CalltoactionControlPanelView.label = _(u'Call to action settings') diff --git a/src/collective/calltoaction/browser/viewlet.py b/src/collective/calltoaction/browser/viewlet.py index 257cadd..46b999c 100644 --- a/src/collective/calltoaction/browser/viewlet.py +++ b/src/collective/calltoaction/browser/viewlet.py @@ -1,9 +1,13 @@ # -*- coding: utf-8 -*- +from collective.calltoaction.interfaces import ICollectiveCalltoactionSettings from collective.calltoaction.portlets.calltoactionportlet import ICallToActionPortlet # noqa +from plone import api from plone.app.layout.viewlets.common import ViewletBase from plone.portlets.interfaces import IPortletManager from plone.portlets.interfaces import IPortletRenderer from plone.portlets.interfaces import IPortletRetriever +from plone.registry.interfaces import IRegistry +from Products.Five import BrowserView from zope.component import getMultiAdapter from zope.component import getUtility @@ -16,11 +20,18 @@ def update(self): # footer = getUtility(IPortletManager, name='plone.footerportlets') # But portlets in Plone 5 need to be based on z3c.form, so it may be # tricky to support Plone 4 and 5 with the same code base. - + registry = getUtility(IRegistry) + settings = registry.forInterface( + ICollectiveCalltoactionSettings, check=False) + self.show_global = settings.show_global + if self.show_global: + target = api.portal.get_navigation_root(self.context) + else: + target = self.context for name in ('plone.leftcolumn', 'plone.rightcolumn'): manager = getUtility(IPortletManager, name=name) retriever = getMultiAdapter( - (self.context, manager), IPortletRetriever) + (target, manager), IPortletRetriever) portlets = retriever.getPortlets() for portlet in portlets: assignment = portlet['assignment'] @@ -48,5 +59,12 @@ def _data_to_portlet(self, manager, data): Adapted from plone.portlets/manager.py _dataToPortlet. """ - return getMultiAdapter((self.context, self.request, self.view, + if self.show_global: + target = api.portal.get_navigation_root(self.context) + # Use dummy view for the target context. + view = BrowserView(target, self.request) + else: + target = self.context + view = self.view + return getMultiAdapter((target, self.request, view, manager, data, ), IPortletRenderer) diff --git a/src/collective/calltoaction/configure.zcml b/src/collective/calltoaction/configure.zcml index 86db8a3..5e05ee7 100644 --- a/src/collective/calltoaction/configure.zcml +++ b/src/collective/calltoaction/configure.zcml @@ -53,4 +53,12 @@ provides="Products.GenericSetup.interfaces.EXTENSION" /> + + diff --git a/src/collective/calltoaction/interfaces.py b/src/collective/calltoaction/interfaces.py index d3dfdbc..f203af8 100644 --- a/src/collective/calltoaction/interfaces.py +++ b/src/collective/calltoaction/interfaces.py @@ -1,8 +1,27 @@ # -*- coding: utf-8 -*- """Module where all interfaces, events and exceptions live.""" +from collective.calltoaction import _ +from zope import schema +from zope.interface import Interface from zope.publisher.interfaces.browser import IDefaultBrowserLayer class ICollectiveCalltoactionLayer(IDefaultBrowserLayer): """Marker interface that defines a browser layer.""" + + +class ICollectiveCalltoactionSettings(Interface): + show_global = schema.Bool( + title=_(u'Show global action'), + description=_( + u'description_global_action', + default=( + u'This looks for a portlet in the navigation root, ' + u'which usually is the portal root. ' + u'It shows it on every page. ' + u'The timeout before showing the call to action, ' + u'is calculated from the first page visit. ' + u'The time spent on the site so far is stored on a cookie.')), + default=False, + ) diff --git a/src/collective/calltoaction/profiles/default/controlpanel.xml b/src/collective/calltoaction/profiles/default/controlpanel.xml new file mode 100644 index 0000000..c483784 --- /dev/null +++ b/src/collective/calltoaction/profiles/default/controlpanel.xml @@ -0,0 +1,20 @@ + + + + + Manage portal + + + diff --git a/src/collective/calltoaction/profiles/default/metadata.xml b/src/collective/calltoaction/profiles/default/metadata.xml index b62c756..e0fbdef 100644 --- a/src/collective/calltoaction/profiles/default/metadata.xml +++ b/src/collective/calltoaction/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 1000 + 1001 diff --git a/src/collective/calltoaction/profiles/default/registry.xml b/src/collective/calltoaction/profiles/default/registry.xml new file mode 100644 index 0000000..6ac7ac3 --- /dev/null +++ b/src/collective/calltoaction/profiles/default/registry.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/collective/calltoaction/profiles/testfixture/portlets.xml b/src/collective/calltoaction/profiles/testfixture/portlets.xml index 623d866..b7788fb 100644 --- a/src/collective/calltoaction/profiles/testfixture/portlets.xml +++ b/src/collective/calltoaction/profiles/testfixture/portlets.xml @@ -13,4 +13,11 @@ 1000 + + diff --git a/src/collective/calltoaction/resources/calltoaction.js b/src/collective/calltoaction/resources/calltoaction.js index f5a92fb..c28d7a3 100644 --- a/src/collective/calltoaction/resources/calltoaction.js +++ b/src/collective/calltoaction/resources/calltoaction.js @@ -3,68 +3,109 @@ $(document).ready(function() { var overlay_set = false; $('.calltoaction-portlet-wrapper').each( function() { + var time_on_site; + var cookie_value; + var el; + var cookiename; + var start; + var timeout; + if (overlay_set) { + return; + } // Check if the user has already seen this overlay. - var cookiename = $(this).attr('data-cookiename'); + cookiename = $(this).attr('data-cookiename'); // Note: readCookie and createCookie are defined in // Products/CMFPlone/skins/plone_ecmascript/cookie_functions.js - if (!overlay_set && !readCookie(cookiename)) { - var timeout = $(this).attr('data-timeout'); - var el = $(this); - setTimeout( - function(){ - // Overlay adapted from http://jquerytools.github.io/demos/overlay/trigger.html - el.overlay({ - // custom top position - top: "center", - fixed: true, - // Before the overlay is gone be active place it correctly - onBeforeLoad: function() { + cookie_value = readCookie(cookiename); + if (cookie_value === 'y') { + // already seen + return; + } + timeout = parseInt($(this).attr('data-timeout')); + if (isNaN(timeout)) { + timeout = 5000; + } + cookie_value = parseInt(cookie_value); + start = cookie_value; + if (isNaN(start)) { + start = new Date().getTime(); + } else { + /* + Say start is 12:00 (but then in milliseconds). + And current time is 12:02. + And timeout is 3 minutes. + Then the new timeout is 1 minute. + */ + time_on_site = new Date().getTime() - start; + if (time_on_site > 3600000) { + // Reset time on site after an hour. + start = new Date().getTime(); + time_on_site = 0; + } + timeout = timeout - time_on_site; + } + if (cookie_value !== start) { + // Remember the session start time in a cookie. + createCookie(cookiename, start, 365); + } + el = $(this); + setTimeout( + function(){ + // Overlay adapted from http://jquerytools.github.io/demos/overlay/trigger.html + el.overlay({ + // custom top position + top: "center", + fixed: true, + // Before the overlay is gone be active place it correctly + onBeforeLoad: function() { - if (el.hasClass("manager_right")){ - el.animate({right: -1000}); - }else{ - el.animate({left: -1000}); - }; + if (el.hasClass("manager_right")){ + el.animate({right: -1000}); + }else{ + el.animate({left: -1000}); + } - }, - // when the overlay is opened, animate our portlet - onLoad: function() { + }, + // when the overlay is opened, animate our portlet + onLoad: function() { - if (el.hasClass("manager_right")){ - el.animate({right: 15}); - } - else { - el.animate({left: 15}); - }; + if (el.hasClass("manager_right")){ + el.animate({right: 15}); + } + else { + el.animate({left: 15}); + } - }, - // some mask tweaks suitable for facebox-looking dialogs - mask: { - // you might also consider a "transparent" color for the mask - color: '#fff', - // load mask a little faster - loadSpeed: 200, - // very transparent - opacity: 0.5 - }, - // disable this for modal dialog-type of overlays - closeOnClick: true, - // load it immediately after the construction - load: true, + }, + // some mask tweaks suitable for facebox-looking dialogs + mask: { + // you might also consider a "transparent" color for the mask + color: '#fff', + // load mask a little faster + loadSpeed: 200, + // very transparent + opacity: 0.5 + }, + // disable this for modal dialog-type of overlays + closeOnClick: true, + // load it immediately after the construction + load: true - }); - - // Set cookie to avoid showing overlay twice to the same - // user. We could do this on certain events, but you have - // to catch them all: onClose of the overlay, clicking on - // a link in the overlay, etcetera. Much easier to simply - // set the cookie at the moment we show the overlay. - createCookie(cookiename, 'y', 365); - }, - timeout); - // We setup only one overlay, otherwise it gets a bit crazy. - overlay_set = true; - }; + }); + + /* + Set cookie to avoid showing overlay twice to the same + user. We could do this on certain events, but you have + to catch them all: onClose of the overlay, clicking on + a link in the overlay, etcetera. Much easier to simply + set the cookie at the moment we show the overlay. + The 'y' value means: yes, the user has seen it. + */ + createCookie(cookiename, 'y', 365); + }, + timeout); + // We setup only one overlay, otherwise it gets a bit crazy. + overlay_set = true; }); }); })(jQuery); diff --git a/src/collective/calltoaction/testing.py b/src/collective/calltoaction/testing.py index e2205ad..e910ffc 100644 --- a/src/collective/calltoaction/testing.py +++ b/src/collective/calltoaction/testing.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from plone import api from plone.app.robotframework.testing import REMOTE_LIBRARY_BUNDLE_FIXTURE from plone.app.testing import applyProfile from plone.app.testing import FunctionalTesting @@ -6,6 +7,9 @@ from plone.app.testing import PloneSandboxLayer from plone.testing import z2 +import collective.calltoaction + + try: # Plone 5 (or maybe Plone 4 with plone.app.contenttypes) from plone.app.contenttypes.testing import ( @@ -14,8 +18,6 @@ # Plone 4 from plone.app.testing import PLONE_FIXTURE -import collective.calltoaction - class CollectiveCalltoactionLayer(PloneSandboxLayer): @@ -29,6 +31,10 @@ def setUpZope(self, app, configurationContext): def setUpPloneSite(self, portal): applyProfile(portal, 'collective.calltoaction:default') + # We have blacklisted context portlets in folder2. + # See the testfixture. + api.content.create( + container=portal, type='Folder', title='Folder2') applyProfile(portal, 'collective.calltoaction:testfixture') diff --git a/src/collective/calltoaction/tests/test_viewlet.py b/src/collective/calltoaction/tests/test_viewlet.py index ce478e6..a2acb07 100644 --- a/src/collective/calltoaction/tests/test_viewlet.py +++ b/src/collective/calltoaction/tests/test_viewlet.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from collective.calltoaction.interfaces import ICollectiveCalltoactionSettings from collective.calltoaction.testing import COLLECTIVE_CALLTOACTION_INTEGRATION_TESTING # noqa from plone import api from plone.app.layout.globals.interfaces import IViewView @@ -6,6 +7,8 @@ from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID from plone.app.testing import TEST_USER_NAME +from plone.registry.interfaces import IRegistry +from zope.component import getUtility from zope.component import queryMultiAdapter from zope.contentprovider.interfaces import IContentProvider from zope.interface import alsoProvides @@ -71,6 +74,37 @@ def test_viewlet(self): self.assertIn(portlet_html, viewlet_html) self.assertIn('data-timeout="1000"', viewlet_html) + def test_portlet_inheritance(self): + portal = self.layer['portal'] + folder = portal.folder2 + request = folder.REQUEST + view = folder.restrictedTraverse('@@plone') + alsoProvides(view, IViewView) + self.assertTrue(IViewView.providedBy(view)) + viewlet_manager = queryMultiAdapter( + (folder, request, view), + IContentProvider, + 'plone.abovecontentbody') + viewlet = queryMultiAdapter( + (folder, request, view, viewlet_manager), + IViewlet, + 'collective.calltoaction') + self.assertTrue(viewlet is not None) + viewlet.update() + # The portlet from the test fixture is blocked. + self.assertEqual(len(viewlet.data), 0) + + # Show the action globally. This ignores portlet inheritance. + registry = getUtility(IRegistry) + settings = registry.forInterface( + ICollectiveCalltoactionSettings, check=False) + self.assertFalse(settings.show_global) + settings.show_global = True + + # Now we should see the portlet + viewlet.update() + self.assertEqual(len(viewlet.data), 1) + def test_suite(): from unittest import TestSuite, makeSuite