View Javadoc
1   /**
2    * This file Copyright (c) 2018-2021 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.ui.editor;
35  
36  import static java.util.stream.Collectors.toList;
37  
38  import info.magnolia.i18nsystem.SimpleTranslator;
39  import info.magnolia.icons.MagnoliaIcons;
40  import info.magnolia.ui.contentapp.Datasource;
41  import info.magnolia.ui.framework.WithImplementation;
42  import info.magnolia.ui.theme.ResurfaceTheme;
43  
44  import java.util.ArrayList;
45  import java.util.Collections;
46  import java.util.HashMap;
47  import java.util.LinkedHashMap;
48  import java.util.List;
49  import java.util.Locale;
50  import java.util.Map;
51  import java.util.concurrent.CompletableFuture;
52  import java.util.stream.Stream;
53  
54  import javax.inject.Inject;
55  
56  import org.apache.commons.lang3.StringUtils;
57  import org.slf4j.Logger;
58  import org.slf4j.LoggerFactory;
59  
60  import com.vaadin.data.Binder;
61  import com.vaadin.data.BinderValidationStatus;
62  import com.vaadin.ui.AbstractOrderedLayout;
63  import com.vaadin.ui.Alignment;
64  import com.vaadin.ui.Button;
65  import com.vaadin.ui.Component;
66  import com.vaadin.ui.CustomField;
67  import com.vaadin.ui.HorizontalLayout;
68  import com.vaadin.ui.Notification;
69  import com.vaadin.ui.VerticalLayout;
70  
71  import lombok.EqualsAndHashCode;
72  import lombok.Getter;
73  import lombok.Setter;
74  
75  /**
76   * Editor view which hosts a number of similar child editors.
77   *
78   * @param <T>
79   *     item type.
80   */
81  public class MultiFormView<T> implements EditorView<T> {
82  
83      private static final Logger log = LoggerFactory.getLogger(MultiFormView.class);
84  
85      private final Binder<Map<?, ?>> binder = new Binder<>();
86      private final MultiFormDefinition<T> definition;
87      private final Locale locale;
88      private final Datasource<T> datasource;
89      private final MultiFormState state = new MultiFormState();
90      private final MultiField multiField;
91  
92      private VerticalLayout rootLayout = new VerticalLayout();
93  
94      @Inject
95      public MultiFormView(MultiFormDefinition<T> definition, LocaleContext localeContext, Datasource<T> datasource) {
96          this.definition = definition;
97          this.locale = localeContext.getDefault();
98          this.datasource = datasource;
99          this.state.entryResolution = create(definition.getEntryResolution(), this.locale);
100         this.rootLayout.setSpacing(true);
101         this.rootLayout.addStyleName("multi-form-view");
102         this.multiField = new MultiField();
103 
104         layout();
105 
106         Binder.BindingBuilder<Map<?, ?>, Map<?, ?>> multiFieldBinder = binder.forField(multiField);
107         if (definition.isRequired()) {
108             multiFieldBinder.asRequired(definition.getRequiredErrorMessage());
109         }
110         multiFieldBinder
111                 .withValidator((new MultiFormViewItemCountValidator(definition.getSizeErrorMessage(), definition.getMinItems(), definition.getMaxItems())))
112                 .withValidationStatusHandler(new ValidationStatusHandler(multiField))
113                 .bind(o -> null, (o, o2) -> {
114                 });
115     }
116 
117     /**
118      * @deprecated since 6.2.3. please use {@link #MultiFormView(MultiFormDefinition, LocaleContext, Datasource)} instead.
119      */
120     @Deprecated
121     public MultiFormView(MultiFormDefinition<T> definition, SimpleTranslator i18n, LocaleContext localeContext, Datasource<T> datasource) {
122         this(definition, localeContext, datasource);
123     }
124 
125     @Override
126     public List<BinderValidationStatus<?>> validate() {
127         return Stream.concat(
128                 Stream.of(binder.validate()),
129                 getAllChildren().flatMap(view -> view.validate().stream())
130         ).collect(toList());
131     }
132 
133     @Override
134     public void write(T item) {
135         Map<EditorView<T>, ItemProviderStrategy<T, T>> children = state.children;
136         state.removedItemAccessors.stream()
137                 .map(accessor -> accessor.read(item, locale))
138                 .forEach(itemToMaybeDelete ->
139                         itemToMaybeDelete
140                                 .ifPresent(this.datasource::remove));
141 
142         List<T> itemOrder = new ArrayList<>();
143         state.order.forEach(subForm -> writeSubForm(item, children, itemOrder, subForm));
144 
145         create(this.definition.getOrderHandler(), locale).applyOrder(itemOrder);
146     }
147 
148     protected void writeSubForm(T item, Map<EditorView<T>, ItemProviderStrategy<T, T>> children, List<T> itemOrder, EditorView<T> subForm) {
149         children.get(subForm).read(item, locale).ifPresent(localisedItem -> {
150             itemOrder.add(localisedItem);
151             subForm.write(localisedItem);
152         });
153     }
154 
155     @Override
156     public Component asVaadinComponent() {
157         return multiField;
158     }
159 
160     @Override
161     public void populate(T item) {
162         getAllChildren().forEach(subForm -> {
163             rootLayout.removeComponent(subForm.asVaadinComponent());
164             subForm.destroy();
165         });
166 
167         state.clear();
168         populateSubNodes(item);
169         layout();
170     }
171 
172     protected void populateSubNodes(T item) {
173         state.entryResolution.resolveForRoot(item).forEach(propertyDefinition -> {
174             if (propertyDefinition != null) {
175                 EditorView<T> subForm = createSubForm(propertyDefinition);
176                 addSubFormToState(subForm, propertyDefinition);
177                 populate(item, subForm);
178             }
179         });
180     }
181 
182     protected EditorView<T> createSubForm(ComplexPropertyDefinition<T> propertyDefinition) {
183         final EditorView<T> subForm = create("child", propertyDefinition.getEditorDefinition());
184         if (StringUtils.isEmpty(subForm.asVaadinComponent().getCaption())) {
185             subForm.asVaadinComponent().setCaption(propertyDefinition.getLabel());
186         }
187         return subForm;
188     }
189 
190     protected void addSubFormToState(EditorView<T> subForm, ComplexPropertyDefinition<T> propertyDefinition) {
191         ItemProviderStrategy<T, T> itemProviderStrategy = create(propertyDefinition.getItemProvider());
192         state.children.put(subForm, itemProviderStrategy);
193         state.order.add(subForm);
194         binder.validate();
195     }
196 
197     private void populate(T item, EditorView<T> subForm) {
198         state.children.get(subForm).read(item, locale).ifPresent(subForm::populate);
199     }
200 
201     public void layout() {
202         rootLayout.removeAllComponents();
203         rootLayout.setMargin(false);
204         state.children.keySet().stream()
205                 .map(this::wrapChildForm)
206                 .forEach(this.rootLayout::addComponent);
207         attachAddButton();
208     }
209 
210     protected Component wrapChildForm(EditorView<T> subForm) {
211         HorizontalLayout wrapLayout = new HorizontalLayout();
212         wrapLayout.setWidth("100%");
213         subForm.asVaadinComponent().addStyleName("multi-form-entry-content");
214 
215         HorizontalLayout buttonLayout = createButtonsLayout(subForm, wrapLayout);
216         wrapLayout.addComponents(subForm.asVaadinComponent(), buttonLayout);
217         wrapLayout.setExpandRatio(subForm.asVaadinComponent(), 1f);
218         wrapLayout.setComponentAlignment(buttonLayout, Alignment.TOP_CENTER);
219         wrapLayout.addStyleName("multi-form-entry");
220 
221         return wrapLayout;
222     }
223 
224     protected HorizontalLayout createButtonsLayout(EditorView<T> subForm, HorizontalLayout wrapLayout) {
225         HorizontalLayout buttonLayout = new HorizontalLayout();
226         buttonLayout.setStyleName("multi-form-entry-control");
227         buttonLayout.setSpacing(false);
228 
229         if (!(definition.getOrderHandler() instanceof MultiFormDefinition.OrderHandlerDefinition.Noop)) {
230             buttonLayout.addComponents(
231                     new Button(MagnoliaIcons.ARROW2_N, e -> onMove(wrapLayout, true)),
232                     new Button(MagnoliaIcons.ARROW2_S, e -> onMove(wrapLayout, false))
233             );
234         }
235         if (definition.isCanRemoveItems()) {
236             Button removeButton = new Button(MagnoliaIcons.TRASH, e -> onDelete(subForm));
237             removeButton.setDescription(definition.getButtonSelectRemoveLabel());
238             buttonLayout.addComponent(removeButton);
239         }
240 
241         buttonLayout.forEach(button -> button.addStyleName(ResurfaceTheme.BUTTON_ICON));
242         return buttonLayout;
243     }
244 
245     protected void attachAddButton() {
246         Button addButton = new Button(definition.getButtonSelectAddLabel());
247         addButton.addStyleName("add-multi-form-entry-button");
248         addButton.addClickListener(e -> {
249             state.entryResolution.pick().thenAccept(propertyDefinition -> {
250                 if (propertyDefinition != null) {
251                     EditorView<T> subForm = createSubForm(propertyDefinition);
252                     addSubFormToState(subForm, propertyDefinition);
253                     subForm.applyDefaults();
254                     rootLayout.addComponent(wrapChildForm(subForm), rootLayout.getComponentCount() - 1);
255                 }
256             }).exceptionally(ex -> {
257                 log.warn("Failed to create a multi field entry", ex);
258                 Notification.show("Failed to create multi field entry");
259                 return null;
260             });
261         });
262 
263         rootLayout.addComponent(addButton);
264     }
265 
266     private Stream<EditorView<T>> getAllChildren() {
267         return state.children.keySet().stream();
268     }
269 
270     protected void onMove(AbstractOrderedLayout movedLayout, boolean moveUp) {
271         int currentPosition = rootLayout.getComponentIndex(movedLayout);
272         int newPosition = moveUp ? currentPosition - 1 : currentPosition + 1;
273 
274         if (currentPosition == 0 && moveUp) {
275             return;
276         }
277 
278         if (newPosition > state.order.size() - 1) {
279             return;
280         }
281 
282         Collections.swap(state.order, currentPosition, newPosition);
283 
284         rootLayout.replaceComponent(markAsDirtyRecursive(currentPosition), markAsDirtyRecursive(newPosition));
285     }
286 
287     private Component markAsDirtyRecursive(int position) {
288         final Component component = rootLayout.getComponent(position);
289         component.markAsDirtyRecursive();
290         return component;
291     }
292 
293     protected void onDelete(EditorView<T> subForm) {
294         rootLayout.removeComponent(subForm.asVaadinComponent().getParent());
295 
296         state.removedItemAccessors.add(state.children.get(subForm));
297         state.children.remove(subForm);
298         state.order.remove(subForm);
299         binder.validate();
300     }
301 
302     protected MultiFormState getState() {
303         return state;
304     }
305 
306     protected Locale getLocale() {
307         return locale;
308     }
309 
310     private class MultiField extends CustomField<Map<?, ?>> {
311 
312         MultiField() {
313             getContent();
314         }
315 
316         @Override
317         protected Component initContent() {
318             return rootLayout;
319         }
320 
321         @Override
322         protected void doSetValue(Map<?, ?> value) {
323         }
324 
325         @Override
326         public Map<?, ?> getValue() {
327             return state.children;
328         }
329 
330         @Override
331         public Map<?, ?> getEmptyValue() {
332             return new HashMap<>();
333         }
334     }
335 
336     protected class MultiFormState {
337 
338         Map<EditorView<T>, ItemProviderStrategy<T, T>> children = new LinkedHashMap<>();
339         List<ItemProviderStrategy<T, T>> removedItemAccessors = new ArrayList<>();
340         List<EditorView<T>> order = new ArrayList<>();
341         protected EntryResolution<T> entryResolution;
342 
343         void clear() {
344             children.clear();
345             removedItemAccessors.clear();
346             order.clear();
347         }
348 
349         public List<EditorView<T>> getOrder() {
350             return order;
351         }
352 
353         public EntryResolution<T> getEntryResolution() {
354             return entryResolution;
355         }
356 
357         public Map<EditorView<T>, ItemProviderStrategy<T, T>> getChildren() {
358             return children;
359         }
360     }
361 
362     /**
363      * Multi-form entry resolution strategy.
364      *
365      * @param <T>
366      */
367     public interface EntryResolution<T> {
368 
369         Stream<ComplexPropertyDefinition<T>> resolveForRoot(T rootDatasource);
370 
371         CompletableFuture<ComplexPropertyDefinition<T>> pick();
372 
373         @Getter
374         @Setter
375         @EqualsAndHashCode
376         class Definition<T> implements WithImplementation<EntryResolution<T>> {
377             private Class<? extends EntryResolution<T>> implementationClass;
378         }
379     }
380 }