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