View Javadoc
1   /**
2    * This file Copyright (c) 2018 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.admincentral;
35  
36  import static com.vaadin.server.Sizeable.Unit.*;
37  import static com.vaadin.ui.Alignment.TOP_LEFT;
38  
39  import info.magnolia.admincentral.banner.BannerContainer;
40  import info.magnolia.admincentral.components.InstanceInfo;
41  import info.magnolia.admincentral.findbar.FindBar;
42  import info.magnolia.admincentral.usermenu.UserMenu;
43  import info.magnolia.admincentral.usermenu.actions.UserActionExecutor;
44  import info.magnolia.cms.security.User;
45  import info.magnolia.context.Context;
46  import info.magnolia.event.EventBus;
47  import info.magnolia.i18nsystem.SimpleTranslator;
48  import info.magnolia.init.MagnoliaConfigurationProperties;
49  import info.magnolia.objectfactory.ComponentProvider;
50  import info.magnolia.objectfactory.Components;
51  import info.magnolia.task.Task;
52  import info.magnolia.task.TasksManager;
53  import info.magnolia.ui.api.app.launcherlayout.AppLauncherLayout;
54  import info.magnolia.ui.api.app.launcherlayout.AppLauncherLayoutManager;
55  import info.magnolia.ui.api.event.AdmincentralEventBus;
56  import info.magnolia.ui.api.location.DefaultLocation;
57  import info.magnolia.ui.api.location.LocationChangedEvent;
58  import info.magnolia.ui.api.location.LocationController;
59  import info.magnolia.ui.api.message.Message;
60  import info.magnolia.ui.api.message.MessageType;
61  import info.magnolia.ui.form.field.converter.NonRoundingConverterFactory;
62  import info.magnolia.ui.framework.ioc.CurrentUiContextReference;
63  import info.magnolia.ui.framework.ioc.SessionStore;
64  import info.magnolia.ui.framework.ioc.UiContextBoundComponentProvider;
65  import info.magnolia.ui.framework.ioc.UiContextReference;
66  import info.magnolia.ui.framework.message.LocalMessageDispatcher;
67  import info.magnolia.ui.framework.message.MessagesManager;
68  
69  import java.util.Arrays;
70  import java.util.HashMap;
71  import java.util.Map;
72  
73  import org.apache.commons.lang3.StringUtils;
74  import org.slf4j.Logger;
75  import org.slf4j.LoggerFactory;
76  
77  import com.google.inject.Key;
78  import com.google.inject.name.Names;
79  import com.vaadin.annotations.JavaScript;
80  import com.vaadin.annotations.Theme;
81  import com.vaadin.server.Page;
82  import com.vaadin.server.Responsive;
83  import com.vaadin.server.ThemeResource;
84  import com.vaadin.server.VaadinRequest;
85  import com.vaadin.server.VaadinSession;
86  import com.vaadin.shared.ui.ContentMode;
87  import com.vaadin.ui.Component;
88  import com.vaadin.ui.HorizontalLayout;
89  import com.vaadin.ui.Image;
90  import com.vaadin.ui.Label;
91  import com.vaadin.ui.UI;
92  import com.vaadin.ui.VerticalLayout;
93  
94  /**
95   * The Resurface UI is the root Vaadin application for the Magnolia 6.0 Admincentral.
96   */
97  // TODO restore pluggability via magnolia.properties OR resolve on webapp level
98  // Don't remove the theme quite yet; keep the old value in magnolia.properties until we unbundle the old admincentral
99  @Theme("resurface-admincentral")
100 @JavaScript({ "jquery-3.3.1.min.js", "jquery-migrate-3.0.1.min.js" })
101 public class ResurfaceUI extends UI {
102     public static final String MESSAGE_COUNT = "MESSAGE_COUNT";
103     private static final String HEADER_INDICATOR = "%s<span class='status icon-radio_fill color-%s'></span>";
104     private static final String[] HEADER_APPS = { "messages-content-app", "tasks-app" };
105     private static final Logger log = LoggerFactory.getLogger(ResurfaceUI.class);
106 
107     private ComponentProvider componentProvider;
108     private LocationController locationController;
109     private AppLauncherLayoutManager appLauncherLayoutManager;
110 
111     private EventBus eventBus;
112 
113     private MessagesManager messagesManager;
114     private LocalMessageDispatcher messageDispatcher;
115     private User user;
116 
117     private SimpleTranslator i18n;
118     private MessagesManager.MessageListener messageIndicatorListener;
119 
120     @Override
121     protected void init(VaadinRequest request) {
122         log.debug("Initializing Resurface Admincentral UI...");
123         getPage().setTitle("Admincentral - Magnolia");
124 
125         // Disable rounding for Vaadin's legacy string-to-floating-point converters
126         VaadinSession currentSession = VaadinSession.getCurrent();
127         currentSession.setConverterFactory(new NonRoundingConverterFactory());
128 
129         log.debug("Initializing ComponentProvider with Admincentral UiContext...");
130         UiContextReference admincentralUiReference = UiContextReference.ofUi(this);
131 
132         Map<Key, Object> instances = new HashMap<>();
133         instances.put(Key.get(CurrentUiContextReference.class), new CurrentUiContextReference());
134         SessionStore.access().createBeanStore(admincentralUiReference, instances);
135         // CurrentUiContextReference has to be put into the BeanStore before the ComponentProvider is constructed
136         this.componentProvider = new UiContextBoundComponentProvider(admincentralUiReference);
137 
138         // TODO extract a sub-component where we can inject normally, or make the UI itself injectable (via UIProvider#createInstance), so these could be final
139         this.locationController = componentProvider.getComponent(LocationController.class);
140         this.appLauncherLayoutManager = componentProvider.getComponent(AppLauncherLayoutManager.class);
141         this.eventBus = Components.getComponentWithAnnotation(EventBus.class, Names.named(AdmincentralEventBus.NAME));
142 
143         messageDispatcher = componentProvider.newInstance(LocalMessageDispatcher.class, this);
144         messagesManager = componentProvider.getComponent(MessagesManager.class);
145         user = componentProvider.getComponent(Context.class).getUser();
146         messagesManager.registerMessagesListener(user.getName(), messageDispatcher);
147 
148         i18n = componentProvider.getComponent(SimpleTranslator.class);
149 
150         UI.getCurrent().setErrorHandler(new AdmincentralErrorHandler(messagesManager, i18n));
151 
152         // Fragment-based location control to/from UI
153         // * below we listen to external changes (e.g. initiated by the client)
154         // * updating the fragment programmatically via Page#setUriFragment (`Shell#setFragment` does this too for compatibility with AppController/AppInstanceController)
155         // TODO restore user-based location control (see DefaultLocationHistoryMapper); ideally without AppLauncherLayoutManager.getLayoutForUser :X
156         handleLocationChange();
157 
158         initUI();
159 
160         String initialFragment = UI.getCurrent().getPage().getUriFragment();
161         checkAndNavigate(initialFragment);
162     }
163 
164     private void initUI() {
165         log.debug("Building Resurface Layout...");
166 
167         VerticalLayout layout = new VerticalLayout();
168         layout.setSizeFull();
169         layout.setMargin(true);
170         layout.setSpacing(true);
171         layout.addStyleName("shell");
172 
173         HorizontalLayout header = new HorizontalLayout();
174         header.setWidth(100, PERCENTAGE);
175         header.setHeight(75, PIXELS);
176         header.setSpacing(true);
177         header.addStyleName("header");
178 
179         // Logo
180         Image logo = new Image(null, new ThemeResource("img/logo-magnolia.svg"));
181         logo.setAlternateText("Magnolia logo");
182         logo.addStyleName("logo");
183 
184         // Find-bar
185         ViewportLayout viewportLayout = new ViewportLayout();
186         FindBar findBar = componentProvider.newInstance(FindBar.class, viewportLayout);
187 
188         // Header components
189         Component findBarTextBox = findBar.asVaadinComponent();
190         Component messagesButton = createMessagesButton();
191         Component tasksButton = createTasksButton();
192         Component instanceInfoComponent = new InstanceInfo(componentProvider.getComponent(MagnoliaConfigurationProperties.class)).asVaadinComponent();
193         Component userMenuComponent = new UserMenu(this, componentProvider.newInstance(UserActionExecutor.class), messagesManager, user).asVaadinComponent();
194 
195         header.addComponents(logo, findBarTextBox, tasksButton, messagesButton, instanceInfoComponent, userMenuComponent);
196         header.setComponentAlignment(logo, TOP_LEFT);
197         header.setExpandRatio(findBarTextBox, 1f);
198 
199         layout.addComponents(header);
200         layout.setExpandRatio(header, 0f);
201         BannerContainer bannerContainer = new BannerContainer(eventBus, locationController, i18n);
202         layout.addComponent(bannerContainer);
203         layout.addComponent(viewportLayout);
204         layout.setExpandRatio(viewportLayout, 1f);
205         Responsive.makeResponsive(layout);
206         setContent(layout);
207     }
208 
209     private Component createMessagesButton() {
210         final Runnable messageLayoutListener = () -> locationController.goTo(new DefaultLocation("app", "messages-content-app"));
211 
212         final long messagesAmount = messagesManager.getMessagesAmount(user.getName(), Arrays.asList(MessageType.values()));
213         Component messagesButton = createIndicatorComponent("Messages", messagesAmount, getMessageConsolidatedStatus(), messageLayoutListener);
214 
215         final Label[] messageCountLabel = new Label[1];
216         ((VerticalLayout) messagesButton).iterator().forEachRemaining(c -> {
217             if (MESSAGE_COUNT.equals(c.getId())) {
218                 messageCountLabel[0] = (Label) c;
219                 updateMessageCountIndicator(messageCountLabel[0], messagesAmount);
220             }
221         });
222 
223         messageIndicatorListener = new MessagesManager.MessageListener() {
224 
225             @Override
226             public void messageSent(Message message) {
227                 final long messagesAmount = messagesManager.getMessagesAmount(user.getName(), Arrays.asList(MessageType.values()));
228                 updateMessageCountIndicator(messageCountLabel[0], messagesAmount);
229             }
230 
231             @Override
232             public void messageCleared(Message message) {
233                 final long messagesAmount = messagesManager.getMessagesAmount(user.getName(), Arrays.asList(MessageType.values()));
234                 updateMessageCountIndicator(messageCountLabel[0], messagesAmount);
235             }
236 
237             @Override
238             public void messageRemoved(String id) {
239                 final long messagesAmount = messagesManager.getMessagesAmount(user.getName(), Arrays.asList(MessageType.values()));
240                 updateMessageCountIndicator(messageCountLabel[0], messagesAmount);
241             }
242         };
243         messagesManager.registerMessagesListener(user.getName(), messageIndicatorListener);
244 
245         return messagesButton;
246     }
247 
248     private void updateMessageCountIndicator(Label messageCountLabel, long numberMessage) {
249         final String messageConsolidatedStatus = getMessageConsolidatedStatus();
250         String messageCountString = String.valueOf(numberMessage);
251         if (StringUtils.isNotEmpty(messageConsolidatedStatus)) {
252             messageCountString = String.format(HEADER_INDICATOR, numberMessage, messageConsolidatedStatus);
253         }
254         messageCountLabel.setValue(messageCountString);
255     }
256 
257     private Component createTasksButton() {
258         TasksManager tasksManager = componentProvider.getComponent(TasksManager.class);
259         final Runnable tasksLayoutListener = () -> locationController.goTo(new DefaultLocation("app", "tasks-app"));
260         String status = "";
261         if (tasksManager.getTasksAmountByUserAndStatus(user.getName(), Arrays.asList(Task.Status.Failed)) > 0) {
262             status = "red";
263         } else if (tasksManager.getTasksAmountByUserAndStatus(user.getName(), Arrays.asList(Task.Status.InProgress)) > 0) {
264             status = "yellow";
265         } else if (tasksManager.getTasksAmountByUserAndStatus(user.getName(), Arrays.asList(Task.Status.Created)) > 0) {
266             status = "green";
267         }
268 
269         final long tasksAmount = tasksManager.getTasksAmountByUserAndStatus(user.getName(), Arrays.asList(Task.Status.Created));
270         return createIndicatorComponent(i18n.translate("tasks.app.caption"), tasksAmount, status, tasksLayoutListener);
271     }
272 
273     private String getMessageConsolidatedStatus() {
274         final int numberOfUnclearedMessages = messagesManager.getNumberOfUnclearedMessagesForUser(user.getName());
275         return numberOfUnclearedMessages > 0 ? "green" : "";
276     }
277 
278     private Component createIndicatorComponent(String caption, long value, String status, Runnable runnable) {
279 
280         VerticalLayout layout = new VerticalLayout();
281         layout.setSizeUndefined();
282         layout.setMargin(true);
283         layout.setSpacing(false);
284         layout.addStyleName("header-component");
285 
286         Label itemCount = new Label();
287 
288         if (StringUtils.isEmpty(status)) {
289             itemCount.setValue(String.valueOf(value));
290         } else {
291             itemCount.setValue(String.format(HEADER_INDICATOR, value, status));
292         }
293         // TODO use MagnoliaIcons for status (standalone Icon server-side component needed?
294         // itemCount.setIcon(MagnoliaIcons.RADIO_FILL);
295         itemCount.addStyleNames("indicator", "heading-2");
296         itemCount.setContentMode(ContentMode.HTML);
297         itemCount.setId(MESSAGE_COUNT);
298 
299         Label label = new Label(caption);
300         label.addStyleName("label");
301 
302         layout.addComponents(itemCount, label);
303         layout.addLayoutClickListener(event -> runnable.run());
304 
305         return layout;
306     }
307 
308     private void handleLocationChange() {
309         Page.getCurrent().addPopStateListener(event -> {
310             String fragment = event.getPage().getUriFragment();
311             checkAndNavigate(fragment);
312         });
313 
314         eventBus.addHandler(LocationChangedEvent.class, event -> {
315             // empty location will occur when going from Magnolia "home page" to an app and back with browser button
316             // we can't set the uri fragment there or a # (dash) will be appended to the uri preventing back navigation from happening
317             if (StringUtils.isBlank(event.getNewLocation().toString())) {
318                 Page.getCurrent().reload();
319             } else {
320                 Page.getCurrent().setUriFragment(event.getNewLocation().toString());
321             }
322         });
323     }
324 
325     private void checkAndNavigate(String fragment) {
326         DefaultLocation newLocation = fragment != null ? new DefaultLocation(fragment) : new DefaultLocation();
327 
328         // verify app & group permissions for incoming navigation changes
329         AppLauncherLayout userLayout = appLauncherLayoutManager.getLayoutForUser(user);
330         if (userLayout.containsApp(newLocation.getAppName()) || isHeaderApp(newLocation.getAppName())) {
331             locationController.goTo(newLocation);
332         } else {
333             log.debug("Denying user {} request to open app {} without permissions", user.getName(), newLocation.getAppName());
334         }
335     }
336 
337     private boolean isHeaderApp(String appName) {
338         // header apps are hidden in launcher, so layout-based security check doesn't cut it
339         // somewhat equivalent to AppControllerImpl#isAllowedToUser former check for shell apps
340         return Arrays.stream(HEADER_APPS)
341                 .anyMatch(headerAppName -> headerAppName.equalsIgnoreCase(appName));
342     }
343 
344     @Override
345     public void detach() {
346         try {
347             SessionStore.access().getBeanStore(UiContextReference.ofUi(this)).clear();
348             messagesManager.unregisterMessagesListener(user.getName(), messageIndicatorListener);
349             messagesManager.unregisterMessagesListener(user.getName(), messageDispatcher);
350             // make sure the error handler covers the detach phase (it does not happen in service phase).
351             super.detach();
352         } catch (Exception e) {
353             getErrorHandler().error(new com.vaadin.server.ErrorEvent(e));
354         }
355     }
356 }