View Javadoc
1   /*
2    * #%L
3    * wcm.io
4    * %%
5    * Copyright (C) 2019 wcm.io
6    * %%
7    * Licensed under the Apache License, Version 2.0 (the "License");
8    * you may not use this file except in compliance with the License.
9    * You may obtain a copy of the License at
10   *
11   *      http://www.apache.org/licenses/LICENSE-2.0
12   *
13   * Unless required by applicable law or agreed to in writing, software
14   * distributed under the License is distributed on an "AS IS" BASIS,
15   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16   * See the License for the specific language governing permissions and
17   * limitations under the License.
18   * #L%
19   */
20  package io.wcm.wcm.ui.granite.components.pathfield;
21  
22  import java.io.IOException;
23  import java.util.ArrayList;
24  import java.util.Iterator;
25  import java.util.List;
26  import java.util.Map;
27  
28  import javax.annotation.PostConstruct;
29  import javax.servlet.ServletException;
30  
31  import org.apache.commons.lang3.Strings;
32  import org.apache.sling.api.SlingHttpServletRequest;
33  import org.apache.sling.api.SlingHttpServletResponse;
34  import org.apache.sling.api.resource.Resource;
35  import org.apache.sling.api.resource.ResourceResolver;
36  import org.apache.sling.api.resource.ValueMap;
37  import org.apache.sling.api.wrappers.ValueMapDecorator;
38  import org.apache.sling.api.wrappers.ValueMapUtil;
39  import org.apache.sling.models.annotations.Model;
40  import org.apache.sling.models.annotations.injectorspecific.SlingObject;
41  import org.jetbrains.annotations.NotNull;
42  import org.jetbrains.annotations.Nullable;
43  import org.osgi.annotation.versioning.ProviderType;
44  
45  import com.adobe.granite.ui.components.ComponentHelper;
46  import com.adobe.granite.ui.components.Config;
47  import com.adobe.granite.ui.components.ExpressionHelper;
48  import com.adobe.granite.ui.components.ds.DataSource;
49  
50  import io.wcm.wcm.ui.granite.pathfield.impl.util.DummyPageContext;
51  import io.wcm.wcm.ui.granite.resource.GraniteUiSyntheticResource;
52  
53  /**
54   * Model for customized columnview Granite UI component for path field.
55   * Logic derived from <code>/libs/granite/ui/components/coral/foundation/columnview/columnview.jsp</code>
56   */
57  @Model(adaptables = SlingHttpServletRequest.class)
58  @ProviderType
59  public final class ColumnView {
60  
61    private static final String FALLBACK_ROOT_RESOURCE = "/";
62    private static final long DEFAULT_PAGINATION_LIMIT = 1000;
63  
64    private static final String NN_DATASOURCE = "datasource";
65    private static final String PN_SIZE = "size";
66    private static final String PN_LIMIT = "limit";
67    private static final String PN_ITEM_RESOURCE_TYPE = "itemResourceType";
68    private static final String PN_SHOW_ROOT = "showRoot";
69    private static final String PN_LOAD_ANCESTORS = "loadAncestors";
70    private static final String PN_PATH = "path";
71  
72    @SlingObject
73    private Resource componentResource;
74    @SlingObject
75    private SlingHttpServletRequest request;
76    @SlingObject
77    private SlingHttpServletResponse response;
78    @SlingObject
79    private ResourceResolver resourceResolver;
80  
81    private Resource currentResource;
82    private final List<Column> columns = new ArrayList<>();
83  
84    @PostConstruct
85    @SuppressWarnings("null")
86    private void activate() {
87      ComponentHelper cmp = new ComponentHelper(new DummyPageContext(request, response));
88      Config cfg = cmp.getConfig();
89      ExpressionHelper ex = cmp.getExpressionHelper();
90  
91      Integer size = ex.get(cfg.get(PN_SIZE, String.class), Integer.class);
92      Long limit = ex.get(cfg.get(PN_LIMIT, String.class), Long.class);
93      String itemResourceType = cfg.get(PN_ITEM_RESOURCE_TYPE, String.class);
94      boolean showRoot = cfg.get(PN_SHOW_ROOT, false);
95      boolean loadAncestors = cfg.get(PN_LOAD_ANCESTORS, false);
96  
97      // make sure we always have a valid root resource
98      Resource rootResource = resourceResolver.getResource(ex.getString(cfg.get("rootPath", FALLBACK_ROOT_RESOURCE)));
99      if (rootResource == null) {
100       rootResource = resourceResolver.getResource(FALLBACK_ROOT_RESOURCE);
101     }
102 
103     // if current resource is invalid or not same or descendant of root resource, set it to root resource
104     String path = ex.getString(cfg.get(PN_PATH, rootResource.getPath()));
105     currentResource = resourceResolver.getResource(path);
106     if (currentResource == null || !isSameResourceOrChild(rootResource, currentResource)) {
107       currentResource = rootResource;
108     }
109 
110     // generate column for root
111     if (showRoot && (Strings.CS.equals(currentResource.getPath(), rootResource.getPath()) || loadAncestors)) {
112       columns.add(getRootColumn(rootResource, itemResourceType));
113     }
114 
115     // generate columns for ancestors
116     if (loadAncestors) {
117       columns.addAll(getAncestorColumns(currentResource, rootResource));
118     }
119 
120     // calculate total number of items to return
121     // NOTE: i assume the logic was intended in a different way, using DEFAULT_PAGINATION_LIMIT as max cap and not
122     // as minimum - but the logic in columnview.jsp is exactly like this
123     long totalSize = DEFAULT_PAGINATION_LIMIT;
124     if (limit != null) {
125       totalSize = Math.max(totalSize, limit);
126     }
127     if (size != null) {
128       totalSize = Math.max(totalSize, size);
129     }
130 
131     // check if a limit value is defined for the data source
132     Long limitFromDataSource = null;
133     Resource datasourceResource = componentResource.getChild(NN_DATASOURCE);
134     if (datasourceResource != null) {
135       limitFromDataSource = ex.get(datasourceResource.getValueMap().get(PN_LIMIT, String.class), Long.class);
136     }
137 
138     DataSource dataSource = null;
139     if (size != null && size >= 20 && datasourceResource != null && limitFromDataSource != null) {
140       // if a limit is configured for the data source or size is at least 20,
141       // calculate a new limit for the data source and overwrite it (synthetic) in the data source definition
142       long newLimit = limitFromDataSource + totalSize - size + 1;
143       dataSource = getDataSource(cmp, currentResource, datasourceResource, newLimit);
144     }
145     else {
146       dataSource = getDataSource(cmp, currentResource);
147       if (size != null) {
148         totalSize = size;
149       }
150     }
151 
152     // generate columns for items
153     columns.add(getCurrentResourceColumn(dataSource, totalSize, currentResource, itemResourceType));
154   }
155 
156   private boolean isSameResourceOrChild(Resource rootResource, Resource resource) {
157     if (Strings.CS.equals(rootResource.getPath(), resource.getPath())) {
158       return true;
159     }
160     else {
161       return Strings.CS.startsWith(resource.getPath(), rootResource.getPath() + "/");
162     }
163   }
164 
165   /**
166    * Returns the current resource for the column view.
167    * @return Current resource
168    */
169   public Resource getCurrentResource() {
170     return this.currentResource;
171   }
172 
173   /**
174    * Returns the list of columns in the column view.
175    * @return List of columns
176    */
177   public List<Column> getColumns() {
178     return this.columns;
179   }
180 
181   /**
182    * Get data source to list children of given resource.
183    * @param cmp Component helper
184    * @param resource Given resource
185    * @return Data source
186    */
187   private DataSource getDataSource(ComponentHelper cmp, Resource resource) {
188     return getDataSource(cmp, resource, null, null);
189   }
190 
191   /**
192    * Get data source to list children of given resource.
193    * @param cmp Component helper
194    * @param resource Resource pointing to current path
195    * @param dataSourceResource Data source resource
196    * @param newLimit Set limit defined in data source to this new value
197    * @return Data source
198    */
199   @SuppressWarnings("java:S112") // allow generic exception
200   private DataSource getDataSource(@NotNull ComponentHelper cmp, @NotNull Resource resource,
201       @Nullable Resource dataSourceResource, @Nullable Long newLimit) {
202     try {
203       /*
204        * by default the path is read from request "path" parameter
205        * here we overwrite it via a synthetic resource because the path may be overwritten by validation logic
206        * to ensure the path is not beyond the configured root path
207        */
208       ValueMap overwriteProperties = new ValueMapDecorator(Map.of(PN_PATH, resource.getPath()));
209       Resource resourceWrapper = GraniteUiSyntheticResource.wrapMerge(componentResource, overwriteProperties);
210 
211       if (dataSourceResource != null && newLimit != null) {
212         // overwrite limit property in data source definition
213         ValueMap overwriteDataSourceProperties = new ValueMapDecorator(Map.of(PN_LIMIT, newLimit));
214         Resource dataSourceResourceWrapper = GraniteUiSyntheticResource.child(resourceWrapper, NN_DATASOURCE,
215             dataSourceResource.getResourceType(),
216             ValueMapUtil.merge(overwriteDataSourceProperties, dataSourceResource.getValueMap()));
217         return cmp.asDataSource(dataSourceResourceWrapper, resourceWrapper);
218       }
219       else {
220         return cmp.getItemDataSource(resourceWrapper);
221       }
222     }
223     catch (ServletException | IOException ex) {
224       throw new RuntimeException("Unable to get data source.", ex);
225     }
226   }
227 
228   /**
229    * Generate column for data source items for current resource.
230    * @param dataSource Data source
231    * @param totalSize Size limit
232    * @param currentResource Current resource
233    * @param itemResourceType Item resource type
234    * @return Column
235    */
236   private static Column getCurrentResourceColumn(DataSource dataSource, long totalSize,
237       Resource currentResource, String itemResourceType) {
238 
239     Iterator<Resource> items = dataSource.iterator();
240 
241     boolean hasMore = false;
242     List<Resource> list = new ArrayList<>();
243     while (items.hasNext() && list.size() < totalSize) {
244       list.add(items.next());
245     }
246     hasMore = items.hasNext();
247     items = list.iterator();
248 
249     Column column = new Column()
250       .isCurrentResource(true)
251       .columnId(currentResource.getPath())
252       .hasMore(hasMore)
253       .metaElement(true);
254     while (items.hasNext()) {
255       Resource item = items.next();
256       column.addItem(new ColumnItem(item)
257         .resourceType(itemResourceType));
258     }
259 
260     return column;
261   }
262 
263   /**
264    * Generate extra column representing the root resource.
265    * @param rootResource Root resource
266    * @param itemResourceType Item resource type
267    * @return Column
268    */
269   private static Column getRootColumn(Resource rootResource, String itemResourceType) {
270     /*
271      * Put a special path for columnId to avoid having the same columnId with the next column to avoid breaking the contract of columnId.
272      * The contract of columnId is that it should be a path of the current column, i.e. the path should be a path representing a parent.
273      * e.g. When columnId = "/", then the column will show the children of this path, such as "/a", "/b".
274      * So for showRoot scenario, if we want to show the item with path = "/", we need to generate the column having a columnId with value of the parent of "/".
275      * Since the cannot have a parent of "/", then we decide to just use a special convention ("parentof:<path>") to indicate this.
276      * Other component (e.g. `.granite-collection-navigator`) reading the columnId can then understand this convention and handle it accordingly.
277      */
278     String columnId = "parentof:" + rootResource.getPath();
279 
280     Column column = new Column()
281       .columnId(columnId)
282       .hasMore(false);
283     column.addItem(new ColumnItem(rootResource)
284       .resourceType(itemResourceType)
285       .active(true));
286     return column;
287   }
288 
289   /**
290    * Generate column for each ancestor.
291    * @param currentResource Current resource
292    * @param rootResource Root resource
293    * @return Columns
294    */
295   private static List<Column> getAncestorColumns(Resource currentResource, Resource rootResource) {
296     List<Column> columns = new ArrayList<>();
297     List<Resource> ancestors = getAncestors(currentResource, rootResource);
298     for (int i = 0; i < ancestors.size(); i++) {
299       Resource r = ancestors.get(i);
300 
301       String activeId;
302       if (i < ancestors.size() - 1) {
303         activeId = ancestors.get(i + 1).getPath();
304       }
305       else {
306         activeId = currentResource.getPath();
307       }
308 
309       Column column = new Column()
310         .columnId(r.getPath())
311         .lazy(true)
312         .activeId(activeId);
313       columns.add(column);
314     }
315     return columns;
316   }
317 
318   /**
319    * Returns the ancestors of the current resource (inclusive) up to the root.
320    * The result is ordered with the root as the first item.
321    */
322   private static List<Resource> getAncestors(Resource currentResource, Resource rootResource) {
323     List<Resource> results = new ArrayList<>();
324 
325     if (currentResource == null || rootResource == null || Strings.CS.equals(currentResource.getPath(), rootResource.getPath())) {
326       return results;
327     }
328 
329     Resource parent = currentResource.getParent();
330     while (parent != null) {
331       results.add(0, parent);
332       if (parent.getPath().equals(rootResource.getPath())) {
333         break;
334       }
335       parent = parent.getParent();
336     }
337     return results;
338   }
339 
340 }