You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@isis.apache.org by ah...@apache.org on 2022/03/04 09:49:31 UTC

[isis] branch master updated: ISIS-2877: refact. around IsisBlobOrClobPanelAbstract

This is an automated email from the ASF dual-hosted git repository.

ahuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/isis.git


The following commit(s) were added to refs/heads/master by this push:
     new d4fff87  ISIS-2877: refact. around IsisBlobOrClobPanelAbstract
d4fff87 is described below

commit d4fff87fbaf0e8e43b32dc54b0f8ecb61b27da58
Author: Andi Huber <ah...@apache.org>
AuthorDate: Fri Mar 4 10:49:20 2022 +0100

    ISIS-2877: refact. around IsisBlobOrClobPanelAbstract
---
 .../isis/commons/internal/base/_Optionals.java     |   1 -
 .../commons/internal/functions/_Functions.java     |  10 +
 .../isis/core/metamodel/commons/Wormhole.java      |  46 ----
 .../core/metamodel/render/ScalarRenderMode.java    |  56 +++++
 .../wicket/model/models/FileUploadModels.java      |  99 +++++++++
 .../wicket/model/models}/HasScalarModel.java       |   6 +-
 .../wicket/model/models/ScalarConvertingModel.java |  74 +++++++
 .../viewer/wicket/model/models/ScalarModel.java    |   7 +-
 .../model/models/ScalarUnwrappingModel.java}       |  41 ++--
 .../ui/components/scalars/ScalarPanelAbstract.java |   1 +
 .../scalars/ScalarPanelTextFieldAbstract.java      |   8 +-
 .../scalars/ScalarPanelTextFieldNumeric.java       |   2 +-
 .../ScalarPanelTextFieldWithTemporalPicker.java    |   2 +-
 .../blobclob/IsisBlobOrClobPanelAbstract.java      | 236 +++++++--------------
 .../components/scalars/blobclob/IsisBlobPanel.java |  14 +-
 .../components/scalars/blobclob/IsisClobPanel.java |  18 +-
 .../scalars/passwd/IsisPasswordPanel.java          |   2 +-
 .../blobclob => util}/ResourceLinkVolatile.java    |   2 +-
 .../org/apache/isis/viewer/wicket/ui/util/Wkt.java |  26 +++
 19 files changed, 392 insertions(+), 259 deletions(-)

diff --git a/commons/src/main/java/org/apache/isis/commons/internal/base/_Optionals.java b/commons/src/main/java/org/apache/isis/commons/internal/base/_Optionals.java
index ec1e8b2..cf083e9 100644
--- a/commons/src/main/java/org/apache/isis/commons/internal/base/_Optionals.java
+++ b/commons/src/main/java/org/apache/isis/commons/internal/base/_Optionals.java
@@ -43,7 +43,6 @@ public class _Optionals {
         return orNullable(orNullable(a, b), c);
     }
 
-
     public <T> OptionalInt toInt(
             final Optional<T> optional,
             final ToIntFunction<? super T> mapper) {
diff --git a/commons/src/main/java/org/apache/isis/commons/internal/functions/_Functions.java b/commons/src/main/java/org/apache/isis/commons/internal/functions/_Functions.java
index 409dff6..e9da1b0 100644
--- a/commons/src/main/java/org/apache/isis/commons/internal/functions/_Functions.java
+++ b/commons/src/main/java/org/apache/isis/commons/internal/functions/_Functions.java
@@ -23,6 +23,7 @@ import java.util.function.BiConsumer;
 import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.function.Supplier;
+import java.util.function.UnaryOperator;
 
 /**
  * <h1>- internal use only -</h1>
@@ -44,6 +45,15 @@ public final class _Functions {
         return t->{};
     }
 
+    // -- PEEK
+
+    public static <T> UnaryOperator<T> peek(final Consumer<T> c) {
+        return x -> {
+            c.accept(x);
+            return x;
+        };
+    }
+
     // --
 
     @FunctionalInterface
diff --git a/core/metamodel/src/main/java/org/apache/isis/core/metamodel/commons/Wormhole.java b/core/metamodel/src/main/java/org/apache/isis/core/metamodel/commons/Wormhole.java
deleted file mode 100644
index 9607f95..0000000
--- a/core/metamodel/src/main/java/org/apache/isis/core/metamodel/commons/Wormhole.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- *  Licensed to the Apache Software Foundation (ASF) under one
- *  or more contributor license agreements.  See the NOTICE file
- *  distributed with this work for additional information
- *  regarding copyright ownership.  The ASF licenses this file
- *  to you under the Apache License, Version 2.0 (the
- *  "License"); you may not use this file except in compliance
- *  with the License.  You may obtain a copy of the License at
- *
- *        http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing,
- *  software distributed under the License is distributed on an
- *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- *  KIND, either express or implied.  See the License for the
- *  specific language governing permissions and limitations
- *  under the License.
- */
-package org.apache.isis.core.metamodel.commons;
-
-/**
- * Provides a mechanism to avoid infinite loops
- * whereby method A -&gt; method B -&gt; method C -&gt; method A and so on.
- */
-public final class Wormhole {
-
-    private Wormhole(){}
-
-    private ThreadLocal<Boolean> inWormhole = ThreadLocal.<Boolean>withInitial(()->Boolean.FALSE);
-
-    public void run(final Runnable runnable) {
-        try {
-            if(inWormhole.get()) {
-                return;
-            }
-            inWormhole.set(true);
-            runnable.run();
-        } finally {
-            inWormhole.remove();
-        }
-    }
-
-    public static void invoke(final Runnable runnable) {
-        new Wormhole().run(runnable);
-    }
-}
diff --git a/core/metamodel/src/main/java/org/apache/isis/core/metamodel/render/ScalarRenderMode.java b/core/metamodel/src/main/java/org/apache/isis/core/metamodel/render/ScalarRenderMode.java
new file mode 100644
index 0000000..9b778bc
--- /dev/null
+++ b/core/metamodel/src/main/java/org/apache/isis/core/metamodel/render/ScalarRenderMode.java
@@ -0,0 +1,56 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.isis.core.metamodel.render;
+
+import org.apache.isis.applib.value.semantics.Parser;
+import org.apache.isis.applib.value.semantics.Renderer;
+
+/**
+ * Mode of representation for a Scalar within the UI.
+ */
+public enum ScalarRenderMode {
+
+    /**
+     * Hiding mode, corresponds to hidden UI components.
+     */
+    HIDING,
+
+    /**
+     * Viewing (HTML-rendering) mode, corresponds to 'compact' UI components.
+     * <p>
+     * In case of value-types, indicates that for value-type to {@link String} conversion,
+     * a {@link Renderer} is required.
+     */
+    VIEWING,
+
+    /**
+     * Editing (text-parsing) mode, corresponds to 'regular' UI components.
+     * <p>
+     * In case of value-types, indicates that for value-type to {@link String} conversion,
+     * and vice versa, a {@link Parser} is required.
+     */
+    EDITING;
+
+    public boolean isHiding() { return this == HIDING; }
+    public boolean isVisible() { return this != HIDING; }
+
+    public boolean isViewing() { return this == VIEWING; }
+    public boolean isEditing() { return this == EDITING; }
+
+}
diff --git a/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/FileUploadModels.java b/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/FileUploadModels.java
new file mode 100644
index 0000000..5619150
--- /dev/null
+++ b/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/FileUploadModels.java
@@ -0,0 +1,99 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.isis.viewer.wicket.model.models;
+
+import java.nio.charset.Charset;
+import java.util.List;
+
+import org.apache.wicket.markup.html.form.upload.FileUpload;
+import org.springframework.lang.Nullable;
+
+import org.apache.isis.applib.value.Blob;
+import org.apache.isis.applib.value.Clob;
+
+import lombok.NonNull;
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+public class FileUploadModels {
+
+    public ScalarConvertingModel<List<FileUpload>, Blob> blob(final @NonNull ScalarModel scalarModel) {
+        return new ScalarConvertingModel<List<FileUpload>, Blob>(scalarModel) {
+
+            private static final long serialVersionUID = 1L;
+
+            @Override
+            protected Blob toScalarValue(final @Nullable List<FileUpload> fileUploads) {
+
+                if(fileUploads==null
+                        || fileUploads.isEmpty()) {
+                    return null;
+                }
+
+                final FileUpload fileUpload = fileUploads.get(0);
+                final String contentType = fileUpload.getContentType();
+                final String clientFileName = fileUpload.getClientFileName();
+                final byte[] bytes = fileUpload.getBytes();
+                final Blob blob = new Blob(clientFileName, contentType, bytes);
+                return blob;
+            }
+
+            @Override
+            protected List<FileUpload> fromScalarValue(final Blob blob) {
+                // not used
+                return null;
+            }
+
+        };
+    }
+
+    public ScalarConvertingModel<List<FileUpload>, Clob> clob(
+            final @NonNull ScalarModel scalarModel,
+            final @NonNull Charset charset) {
+
+        return new ScalarConvertingModel<List<FileUpload>, Clob>(scalarModel) {
+
+            private static final long serialVersionUID = 1L;
+
+            @Override
+            protected Clob toScalarValue(final @Nullable List<FileUpload> fileUploads) {
+
+                if(fileUploads==null
+                        || fileUploads.isEmpty()) {
+                    return null;
+                }
+
+                final FileUpload fileUpload = fileUploads.get(0);
+                final String contentType = fileUpload.getContentType();
+                final String clientFileName = fileUpload.getClientFileName();
+                final String str = new String(fileUpload.getBytes(), charset);
+                final Clob clob = new Clob(clientFileName, contentType, str);
+                return clob;
+            }
+
+            @Override
+            protected List<FileUpload> fromScalarValue(final Clob clob) {
+                // not used
+                return null;
+            }
+
+        };
+    }
+
+}
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/HasScalarModel.java b/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/HasScalarModel.java
similarity index 84%
rename from viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/HasScalarModel.java
rename to viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/HasScalarModel.java
index 6f35a01..bbfaf70 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/HasScalarModel.java
+++ b/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/HasScalarModel.java
@@ -16,12 +16,12 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.apache.isis.viewer.wicket.ui.components.scalars;
+package org.apache.isis.viewer.wicket.model.models;
 
-import org.apache.isis.viewer.wicket.model.models.ScalarModel;
+import java.io.Serializable;
 
 @FunctionalInterface
-public interface HasScalarModel {
+public interface HasScalarModel extends Serializable {
 
     ScalarModel scalarModel();
 
diff --git a/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/ScalarConvertingModel.java b/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/ScalarConvertingModel.java
new file mode 100644
index 0000000..a11b861
--- /dev/null
+++ b/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/ScalarConvertingModel.java
@@ -0,0 +1,74 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.isis.viewer.wicket.model.models;
+
+import org.apache.wicket.model.ChainingModel;
+import org.springframework.lang.Nullable;
+
+import org.apache.isis.commons.internal.base._Casts;
+import org.apache.isis.core.metamodel.spec.ManagedObject;
+import org.apache.isis.core.metamodel.spec.ManagedObjects;
+
+import lombok.NonNull;
+import lombok.val;
+
+/**
+ * @param <T> foreign type
+ * @param <V> scalar value type
+ */
+public abstract class ScalarConvertingModel<T, V>
+extends ChainingModel<T> {
+
+    private static final long serialVersionUID = 1L;
+
+    protected ScalarConvertingModel(final @NonNull ScalarModel scalarModel) {
+        super(scalarModel);
+    }
+
+    @Override
+    public void setObject(final T modelValue) {
+        val scalarModel = scalarModel();
+        val value = toScalarValue(modelValue);
+        val objectAdapter = value != null
+                ? scalarModel().getCommonContext().getObjectManager().adapt(value)
+                : ManagedObject.empty(scalarModel.getScalarTypeSpec());
+        scalarModel.setObject(objectAdapter);
+    }
+
+    @Override
+    public T getObject() {
+        val adapter = scalarModel().getObject();
+        final V scalarValue = !ManagedObjects.isNullOrUnspecifiedOrEmpty(adapter)
+                ? _Casts.uncheckedCast(adapter.getPojo())
+                : null;
+        return fromScalarValue(scalarValue);
+    }
+
+    // -- HOOKS
+
+    protected abstract V toScalarValue(@Nullable T t);
+    protected abstract T fromScalarValue(@Nullable V value);
+
+    // -- HELPER
+
+    protected ScalarModel scalarModel() {
+        return (ScalarModel) super.getTarget();
+    }
+
+}
diff --git a/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/ScalarModel.java b/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/ScalarModel.java
index f4ee515..e2152d8 100644
--- a/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/ScalarModel.java
+++ b/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/ScalarModel.java
@@ -22,6 +22,7 @@ import java.util.List;
 import java.util.Optional;
 
 import org.apache.wicket.model.ChainingModel;
+import org.apache.wicket.model.IModel;
 
 import org.apache.isis.applib.annotation.PromptStyle;
 import org.apache.isis.commons.collections.Can;
@@ -137,6 +138,10 @@ implements HasRenderingHints, ScalarUiModel, LinksProvider, FormExecutorContext
         return proposedValue().getValue().getValue();
     }
 
+    public <T> IModel<T> unwrapped(final Class<T> type) {
+        return new ScalarUnwrappingModel<T>(type, this);
+    }
+
     /**
      * Sets given ManagedObject as new proposed value.
      * (override, so we don't return the target model, we are chained to)
@@ -349,6 +354,4 @@ implements HasRenderingHints, ScalarUiModel, LinksProvider, FormExecutorContext
         //getPendingPropertyModel().getValue().setValue(null);
     }
 
-
-
 }
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/TextFieldValueModel.java b/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/ScalarUnwrappingModel.java
similarity index 64%
rename from viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/TextFieldValueModel.java
rename to viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/ScalarUnwrappingModel.java
index 1005ff4..73a8459 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/TextFieldValueModel.java
+++ b/viewers/wicket/model/src/main/java/org/apache/isis/viewer/wicket/model/models/ScalarUnwrappingModel.java
@@ -16,31 +16,35 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.apache.isis.viewer.wicket.ui.components.scalars;
+package org.apache.isis.viewer.wicket.model.models;
 
-import java.io.Serializable;
-
-import org.apache.wicket.markup.html.form.TextField;
 import org.apache.wicket.model.ChainingModel;
-import org.apache.wicket.model.Model;
+import org.springframework.util.ClassUtils;
 
+import org.apache.isis.commons.internal.base._Casts;
 import org.apache.isis.core.metamodel.spec.ManagedObject;
 import org.apache.isis.core.metamodel.spec.ManagedObjects;
-import org.apache.isis.viewer.wicket.model.models.ScalarModel;
 
+import lombok.Getter;
+import lombok.NonNull;
 import lombok.val;
 
 /**
- * For custom {@link ScalarPanelTextFieldAbstract}s to use as the {@link Model}
- * of their {@link TextField} (as constructed in {@link ScalarPanelTextFieldAbstract#createTextField(String)}).
+ * Wraps and unwraps the contained value within {@link ManagedObject},
+ * as provided by a {@link ScalarModel}.
  */
-public class TextFieldValueModel<T extends Serializable>
+public class ScalarUnwrappingModel<T>
 extends ChainingModel<T> {
 
     private static final long serialVersionUID = 1L;
 
-    public TextFieldValueModel(final HasScalarModel scalarModelHolder) {
-        super(scalarModelHolder);
+    @Getter @NonNull private final Class<T> type;
+
+    public ScalarUnwrappingModel(
+            final @NonNull Class<T> type,
+            final @NonNull ScalarModel scalarModel) {
+        super(scalarModel);
+        this.type = type;
     }
 
     @Override
@@ -51,9 +55,7 @@ extends ChainingModel<T> {
 
     @Override
     public void setObject(final T object) {
-
         val scalarModel = scalarModel();
-
         if (object == null) {
             scalarModel.setObject(null);
         } else {
@@ -64,15 +66,18 @@ extends ChainingModel<T> {
 
     // -- HELPER
 
-    @SuppressWarnings("unchecked")
     private T unwrap(final ManagedObject objectAdapter) {
-        return (T) ManagedObjects.UnwrapUtil.single(objectAdapter);
+        val pojo = ManagedObjects.UnwrapUtil.single(objectAdapter);
+        if(pojo==null
+                || !ClassUtils.resolvePrimitiveIfNecessary(type)
+                        .isAssignableFrom(ClassUtils.resolvePrimitiveIfNecessary(pojo.getClass()))) {
+            return null;
+        }
+        return _Casts.uncheckedCast(pojo);
     }
 
     private ScalarModel scalarModel() {
-        return ((HasScalarModel) super.getTarget())
-                .scalarModel();
+        return (ScalarModel) super.getTarget();
     }
 
-
 }
\ No newline at end of file
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelAbstract.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelAbstract.java
index bceb653..e0c4070 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelAbstract.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelAbstract.java
@@ -54,6 +54,7 @@ import org.apache.isis.viewer.common.model.feature.ParameterUiModel;
 import org.apache.isis.viewer.wicket.model.links.LinkAndLabel;
 import org.apache.isis.viewer.wicket.model.models.ActionPrompt;
 import org.apache.isis.viewer.wicket.model.models.ActionPromptProvider;
+import org.apache.isis.viewer.wicket.model.models.HasScalarModel;
 import org.apache.isis.viewer.wicket.model.models.InlinePromptContext;
 import org.apache.isis.viewer.wicket.model.models.ScalarModel;
 import org.apache.isis.viewer.wicket.model.models.ScalarPropertyModel;
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldAbstract.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldAbstract.java
index 300716a..e9931f1 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldAbstract.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldAbstract.java
@@ -112,13 +112,13 @@ extends ScalarPanelWithFormFieldAbstract<T> {
         val converter = getConverter(scalarModel());
         return getTextFieldVariant().isSingleLine()
                 ? Wkt.textFieldWithConverter(
-                        id, newTextFieldValueModel(), type, converter)
+                        id, unwrappedModel(), type, converter)
                 : setRowsAndMaxLengthAttributesOn(Wkt.textAreaWithConverter(
-                        id, newTextFieldValueModel(), type, converter));
+                        id, unwrappedModel(), type, converter));
     }
 
-    protected final TextFieldValueModel<T> newTextFieldValueModel() {
-        return new TextFieldValueModel<>(this);
+    protected final IModel<T> unwrappedModel() {
+        return scalarModel().unwrapped(type);
     }
 
     // --
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldNumeric.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldNumeric.java
index 53147c3..0bed33f 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldNumeric.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldNumeric.java
@@ -46,7 +46,7 @@ extends ScalarPanelTextFieldWithValueSemantics<T> {
     protected final Component createComponentForCompact() {
         val label = Wkt.labelAddWithConverter(
                 getCompactFragment(CompactType.SPAN),
-                ID_SCALAR_IF_COMPACT, newTextFieldValueModel(), type, getConverter(scalarModel()));
+                ID_SCALAR_IF_COMPACT, unwrappedModel(), type, getConverter(scalarModel()));
         label.setEnabled(false);
         return label;
     }
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldWithTemporalPicker.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldWithTemporalPicker.java
index 166c5e1..c4966db 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldWithTemporalPicker.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/ScalarPanelTextFieldWithTemporalPicker.java
@@ -48,7 +48,7 @@ extends ScalarPanelTextFieldWithValueSemantics<T>  {
     @Override
     protected final TextField<T> createTextField(final String id) {
         return new TextFieldWithDateTimePicker<T>(
-                super.getCommonContext(), id, newTextFieldValueModel(), type, getConverter(scalarModel()));
+                super.getCommonContext(), id, unwrappedModel(), type, getConverter(scalarModel()));
     }
 
 
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisBlobOrClobPanelAbstract.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisBlobOrClobPanelAbstract.java
index 4943c46..743bc1d 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisBlobOrClobPanelAbstract.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisBlobOrClobPanelAbstract.java
@@ -21,7 +21,6 @@ package org.apache.isis.viewer.wicket.ui.components.scalars.blobclob;
 import java.util.List;
 import java.util.Optional;
 
-import org.apache.wicket.AttributeModifier;
 import org.apache.wicket.Component;
 import org.apache.wicket.MarkupContainer;
 import org.apache.wicket.ajax.AjaxRequestTarget;
@@ -33,11 +32,12 @@ import org.apache.wicket.markup.html.form.upload.FileUpload;
 import org.apache.wicket.markup.html.form.upload.FileUploadField;
 import org.apache.wicket.markup.html.image.Image;
 import org.apache.wicket.model.IModel;
-import org.apache.wicket.model.Model;
 import org.apache.wicket.request.resource.IResource;
+import org.springframework.lang.Nullable;
 
 import org.apache.isis.applib.value.Blob;
 import org.apache.isis.applib.value.NamedWithMimeType;
+import org.apache.isis.core.metamodel.render.ScalarRenderMode;
 import org.apache.isis.core.metamodel.spec.ManagedObjects;
 import org.apache.isis.viewer.wicket.model.models.ScalarModel;
 import org.apache.isis.viewer.wicket.ui.components.scalars.ScalarPanelWithFormFieldAbstract;
@@ -47,6 +47,9 @@ import org.apache.isis.viewer.wicket.ui.util.Components;
 import org.apache.isis.viewer.wicket.ui.util.Tooltips;
 import org.apache.isis.viewer.wicket.ui.util.Wkt;
 
+import static org.apache.isis.commons.internal.functions._Functions.peek;
+
+import lombok.NonNull;
 import lombok.val;
 
 import de.agilecoders.wicket.extensions.markup.html.bootstrap.form.fileinput.BootstrapFileInputField;
@@ -63,15 +66,11 @@ extends ScalarPanelWithFormFieldAbstract<T> {
     private static final String ID_SCALAR_IF_COMPACT_DOWNLOAD = "scalarIfCompactDownload";
 
     private Image wicketImage;
-
     private FileUploadField fileUploadField;
     private Label fileNameLabel;
 
-    protected enum InputFieldVisibility {
-        VISIBLE, NOT_VISIBLE
-    }
-    protected enum InputFieldEditability{
-        EDITABLE, NOT_EDITABLE
+    protected IsisBlobOrClobPanelAbstract(final String id, final ScalarModel scalarModel, final Class<T> type) {
+        super(id, scalarModel, type);
     }
 
     // generic type mismatch; no issue as long as we don't use conversion
@@ -91,8 +90,8 @@ extends ScalarPanelWithFormFieldAbstract<T> {
         } else {
             Components.permanentlyHide(formGroup, ID_IMAGE);
         }
-        updateFileNameLabel(ID_FILE_NAME, formGroup);
-        updateDownloadLink(ID_SCALAR_IF_REGULAR_DOWNLOAD, formGroup);
+        createFileNameLabel(ID_FILE_NAME, formGroup);
+        createDownloadLink(ID_SCALAR_IF_REGULAR_DOWNLOAD, formGroup);
     }
 
     // //////////////////////////////////////
@@ -105,7 +104,6 @@ extends ScalarPanelWithFormFieldAbstract<T> {
         return InlinePromptConfig.notSupported();
     }
 
-
     // //////////////////////////////////////
 
     @Override
@@ -120,7 +118,7 @@ extends ScalarPanelWithFormFieldAbstract<T> {
     @Override
     protected Component createComponentForCompact() {
         final MarkupContainer scalarIfCompact = new WebMarkupContainer(ID_SCALAR_IF_COMPACT);
-        updateDownloadLink(ID_SCALAR_IF_COMPACT_DOWNLOAD, scalarIfCompact);
+        createDownloadLink(ID_SCALAR_IF_COMPACT_DOWNLOAD, scalarIfCompact);
 //        if(downloadLink != null) {
 //            updateFileNameLabel(ID_FILE_NAME_IF_COMPACT, downloadLink);
 //            Components.permanentlyHide(downloadLink, ID_FILE_NAME_IF_COMPACT);
@@ -128,71 +126,27 @@ extends ScalarPanelWithFormFieldAbstract<T> {
         return scalarIfCompact;
     }
 
-
-    // //////////////////////////////////////
-
-    private Image asWicketImage(final String id) {
-
-        val adapter = getModel().getObject();
-        if(adapter == null) {
-            return null;
-        }
-
-        val object = adapter.getPojo();
-        if(!(object instanceof Blob)) {
-            return null;
-        }
-
-        val blob = (Blob)object;
-
-        return WicketImageUtil.asWicketImage(id, blob).orElse(null);
-    }
-
-
     // //////////////////////////////////////
 
     @Override
     protected void onInitializeNotEditable() {
-        updateRegularFormComponents(InputFieldVisibility.VISIBLE, InputFieldEditability.NOT_EDITABLE, null, Optional.empty());
+        updateRegularFormComponents(ScalarRenderMode.VIEWING, null, Optional.empty());
     }
 
     @Override
     protected void onInitializeReadonly(final String disableReason) {
-        updateRegularFormComponents(InputFieldVisibility.VISIBLE, InputFieldEditability.NOT_EDITABLE, null, Optional.empty());
+        updateRegularFormComponents(ScalarRenderMode.VIEWING, null, Optional.empty());
     }
 
     @Override
     protected void onInitializeEditable() {
-        updateRegularFormComponents(InputFieldVisibility.VISIBLE, InputFieldEditability.EDITABLE, null, Optional.empty());
+        updateRegularFormComponents(ScalarRenderMode.EDITING, null, Optional.empty());
     }
 
     private FileUploadField createFileUploadField(final String componentId) {
-        final BootstrapFileInputField fileUploadField = new BootstrapFileInputField(
-                componentId, new IModel<List<FileUpload>>() {
-
-                    private static final long serialVersionUID = 1L;
-
-                    @Override
-                    public void setObject(final List<FileUpload> fileUploads) {
-                        if (fileUploads == null || fileUploads.isEmpty()) {
-                            return;
-                        }
+        val fileUploadField = new BootstrapFileInputField(
+                componentId, fileUploadModel());
 
-                        val blob = getBlobOrClobFrom(fileUploads);
-                        val objectAdapter = scalarModel().getCommonContext().getObjectManager().adapt(blob);
-                        getModel().setObject(objectAdapter);
-                    }
-
-                    @Override
-                    public void detach() {
-                    }
-
-                    @Override
-                    public List<FileUpload> getObject() {
-                        return null;
-                    }
-
-                });
         fileUploadField.getConfig().showUpload(false).mainClass("input-group-sm");
         return fileUploadField;
     }
@@ -200,19 +154,17 @@ extends ScalarPanelWithFormFieldAbstract<T> {
     @Override
     protected void onNotEditable(final String disableReason, final Optional<AjaxRequestTarget> target) {
         updateRegularFormComponents(
-                InputFieldVisibility.VISIBLE, InputFieldEditability.NOT_EDITABLE,
+                ScalarRenderMode.VIEWING,
                 disableReason, target);
     }
 
     @Override
     protected void onEditable(final Optional<AjaxRequestTarget> target) {
         updateRegularFormComponents(
-                InputFieldVisibility.VISIBLE, InputFieldEditability.EDITABLE,
+                ScalarRenderMode.VIEWING,
                 null, target);
     }
 
-    protected abstract T getBlobOrClobFrom(final List<FileUpload> fileUploads);
-
     @SuppressWarnings("unchecked")
     private Optional<T> getBlobOrClob(final ScalarModel model) {
         val adapter = model.getObject();
@@ -220,100 +172,74 @@ extends ScalarPanelWithFormFieldAbstract<T> {
         return Optional.ofNullable((T)pojo);
     }
 
-    protected IsisBlobOrClobPanelAbstract(final String id, final ScalarModel scalarModel, final Class<T> type) {
-        super(id, scalarModel, type);
-    }
+    protected abstract IModel<List<FileUpload>> fileUploadModel();
+    protected abstract IResource newResource(final T namedWithMimeType);
+
+    // -- HELPER
 
     private void updateRegularFormComponents(
-            final InputFieldVisibility visibility,
-            final InputFieldEditability editability,
+            final ScalarRenderMode renderMode,
             final String disabledReason,
             final Optional<AjaxRequestTarget> target) {
 
         final MarkupContainer formComponent = getComponentForRegular();
-        sync(formComponent, visibility, editability, disabledReason, target);
+        setRenderModeOn(formComponent, renderMode, disabledReason, target);
 
-        // sonar-ignore-on (detects potential NPE, which is a false positive here)
-        final Component component = formComponent.get(ID_SCALAR_VALUE);
-        // sonar-ignore-off
-        final InputFieldVisibility editingWidgetVisibility = editability == InputFieldEditability.EDITABLE
-                ? InputFieldVisibility.VISIBLE
-                : InputFieldVisibility.NOT_VISIBLE;
-        sync(component, editingWidgetVisibility, null, disabledReason, target);
+        final Component scalarValueComponent = formComponent.get(ID_SCALAR_VALUE);
+        final ScalarRenderMode editingWidgetVisibility = renderMode.isEditing()
+                ? ScalarRenderMode.EDITING
+                : ScalarRenderMode.HIDING;
+        setRenderModeOn(scalarValueComponent, editingWidgetVisibility, disabledReason, target);
 
-        addAcceptFilterTo(component);
-        fileNameLabel = updateFileNameLabel(ID_FILE_NAME, formComponent);
+        addAcceptFilterTo(scalarValueComponent);
+        fileNameLabel = createFileNameLabel(ID_FILE_NAME, formComponent);
 
-        updateClearLink(editingWidgetVisibility, null, target);
+        updateClearLink(editingWidgetVisibility, target);
 
         // the visibility of download link is intentionally 'backwards';
         // if in edit mode then do NOT show
-        final MarkupContainer downloadLink = updateDownloadLink(ID_SCALAR_IF_REGULAR_DOWNLOAD, formComponent);
-        sync(downloadLink, visibility, editability, disabledReason, target);
+        final MarkupContainer downloadLink = createDownloadLink(ID_SCALAR_IF_REGULAR_DOWNLOAD, formComponent);
+        setRenderModeOn(downloadLink, renderMode, disabledReason, target);
         // ditto any image
-        sync(wicketImage, visibility, editability, disabledReason, target);
+        setRenderModeOn(wicketImage, renderMode, disabledReason, target);
     }
 
-    private void sync(
-            final Component component,
-            final InputFieldVisibility visibility,
-            final InputFieldEditability editability,
-            final String disabledReason,
-            final Optional<AjaxRequestTarget> target) {
-
-        if(component == null) {
-            return;
-        }
-        component.setOutputMarkupId(true); // enable ajax link
-
-        if(visibility != null) {
-            component.setVisible(visibility == InputFieldVisibility.VISIBLE);
-            target.ifPresent(ajax->{
-                Components.addToAjaxRequest(ajax, component);
-            });
-
-        }
-
+    private void setRenderModeOn(
+            final @Nullable Component component,
+            final @NonNull  ScalarRenderMode renderMode,
+            final @Nullable String disabledReason,
+            final @NonNull  Optional<AjaxRequestTarget> target) {
 
-        if(editability != null) {
+        if(component==null) return;
 
-            //            // dynamic disablement doesn't yet work, this exception is thrown when form is submitted:
-            //            //
-            //            // Caused by: java.lang.IllegalStateException: ServletRequest does not contain multipart content.
-            //            // One possible solution is to explicitly call Form.setMultipart(true), Wicket tries its best to
-            //            // auto-detect multipart forms but there are certain situation where it cannot.
-            //
-            //            component.setEnabled(editability == InputFieldEditability.EDITABLE);
-            //
-            //            final AttributeModifier title = new AttributeModifier("title", Model.of(disabledReason != null ? disabledReason : ""));
-            //            component.add(title);
-            //
-            //            if (target != null) {
-            //                target.add(component);
-            //            }
+        component.setOutputMarkupId(true); // enable ajax link
+        component.setVisible(renderMode.isVisible());
+        target.ifPresent(ajax->{
+            Components.addToAjaxRequest(ajax, component);
+        });
 
-        }
-    }
+//        // dynamic disablement doesn't yet work, this exception is thrown when form is submitted:
+//        //
+//        // Caused by: java.lang.IllegalStateException: ServletRequest does not contain multipart content.
+//        // One possible solution is to explicitly call Form.setMultipart(true), Wicket tries its best to
+//        // auto-detect multipart forms but there are certain situation where it cannot.
+//
+//        component.setEnabled(editability == InputFieldEditability.EDITABLE);
+//
+//        final AttributeModifier title = new AttributeModifier("title", Model.of(disabledReason != null ? disabledReason : ""));
+//        component.add(title);
+//
+//        if (target != null) {
+//            target.add(component);
+//        }
 
-    private String getAcceptFilter(){
-        return scalarModel().getFileAccept();
     }
 
     private void addAcceptFilterTo(final Component component){
-        final String filter = getAcceptFilter();
-        if(component==null || filter==null || filter.isEmpty())
-            return; // ignore
-        class AcceptAttributeModel extends Model<String> {
-            private static final long serialVersionUID = 1L;
-            @Override
-            public String getObject() {
-                return filter;
-            }
-        }
-        component.add(new AttributeModifier("accept", new AcceptAttributeModel()));
+        Wkt.attributeAppend(component, "accept", scalarModel().getFileAccept());
     }
 
-    private Label updateFileNameLabel(final String idFileName, final MarkupContainer formComponent) {
+    private Label createFileNameLabel(final String idFileName, final MarkupContainer formComponent) {
 
         val fileNameLabel = Wkt.labelAdd(formComponent, idFileName, ()->
             getBlobOrClobFromModel()
@@ -325,12 +251,10 @@ extends ScalarPanelWithFormFieldAbstract<T> {
     }
 
     private void updateClearLink(
-            final InputFieldVisibility visibility,
-            final InputFieldEditability editability,
+            final ScalarRenderMode renderMode,
             final Optional<AjaxRequestTarget> target) {
 
         final MarkupContainer formComponent = getComponentForRegular();
-        formComponent.setOutputMarkupId(true); // enable ajax link
 
         final AjaxLink<Void> ajaxLink = Wkt.linkAdd(formComponent, ID_SCALAR_IF_REGULAR_CLEAR, ajaxTarget->{
             setEnabled(false);
@@ -343,7 +267,7 @@ extends ScalarPanelWithFormFieldAbstract<T> {
 
         final Optional<T> blobOrClob = getBlobOrClobFromModel();
         final Component clearButton = formComponent.get(ID_SCALAR_IF_REGULAR_CLEAR);
-        clearButton.setVisible(blobOrClob.isPresent() && visibility == InputFieldVisibility.VISIBLE);
+        clearButton.setVisible(blobOrClob.isPresent() && renderMode.isVisible());
         clearButton.setEnabled(blobOrClob.isPresent());
 
         target.ifPresent(ajax->{
@@ -354,33 +278,27 @@ extends ScalarPanelWithFormFieldAbstract<T> {
 
     }
 
-    private MarkupContainer updateDownloadLink(final String downloadId, final MarkupContainer parent) {
-        val resourceLink = createResourceLink(downloadId);
-        if(resourceLink != null) {
-            parent.addOrReplace(resourceLink);
-            Tooltips.addTooltip(resourceLink, "download");
-        } else {
-            Components.permanentlyHide(parent, downloadId);
-        }
-        return resourceLink;
-    }
-
-    private ResourceLinkVolatile createResourceLink(final String id) {
+    private MarkupContainer createDownloadLink(final String id, final MarkupContainer parent) {
         return getBlobOrClobFromModel()
         .map(this::newResource)
-        .map(resource->new ResourceLinkVolatile(id, resource))
-        .orElse(null);
+        .map(resource->Wkt.downloadLinkNoCache(id, resource))
+        .map(peek(downloadLink->{
+            parent.addOrReplace(downloadLink);
+            Tooltips.addTooltip(downloadLink, "download");
+        }))
+        .orElseGet(()->{
+            Components.permanentlyHide(parent, id);
+            return null;
+        });
     }
 
     private Optional<T> getBlobOrClobFromModel() {
         return getBlobOrClob(getModel());
     }
 
-
-    /**
-     * Mandatory hook method.
-     */
-    protected abstract IResource newResource(final T namedWithMimeType);
-
+    private Image asWicketImage(final String id) {
+        val blob = scalarModel().unwrapped(Blob.class).getObject();
+        return WicketImageUtil.asWicketImage(id, blob).orElse(null);
+    }
 
 }
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisBlobPanel.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisBlobPanel.java
index 9f27f73..583ccc9 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisBlobPanel.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisBlobPanel.java
@@ -22,10 +22,12 @@ package org.apache.isis.viewer.wicket.ui.components.scalars.blobclob;
 import java.util.List;
 
 import org.apache.wicket.markup.html.form.upload.FileUpload;
+import org.apache.wicket.model.IModel;
 import org.apache.wicket.request.resource.ByteArrayResource;
 import org.apache.wicket.request.resource.IResource;
 
 import org.apache.isis.applib.value.Blob;
+import org.apache.isis.viewer.wicket.model.models.FileUploadModels;
 import org.apache.isis.viewer.wicket.model.models.ScalarModel;
 
 /**
@@ -39,15 +41,9 @@ public class IsisBlobPanel extends IsisBlobOrClobPanelAbstract<Blob> {
         super(id, model, Blob.class);
     }
 
-
     @Override
-    protected Blob getBlobOrClobFrom(final List<FileUpload> fileUploads) {
-        final FileUpload fileUpload = fileUploads.get(0);
-        final String contentType = fileUpload.getContentType();
-        final String clientFileName = fileUpload.getClientFileName();
-        final byte[] bytes = fileUpload.getBytes();
-        final Blob blob = new Blob(clientFileName, contentType, bytes);
-        return blob;
+    protected IModel<List<FileUpload>> fileUploadModel() {
+        return FileUploadModels.blob(scalarModel());
     }
 
     @Override
@@ -55,6 +51,4 @@ public class IsisBlobPanel extends IsisBlobOrClobPanelAbstract<Blob> {
         return new ByteArrayResource(blob.getMimeType().getBaseType(), blob.getBytes(), blob.getName());
     }
 
-
-
 }
\ No newline at end of file
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisClobPanel.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisClobPanel.java
index f6c5aca..e41fb3b 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisClobPanel.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/IsisClobPanel.java
@@ -19,22 +19,23 @@
 package org.apache.isis.viewer.wicket.ui.components.scalars.blobclob;
 
 
-import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 
 import org.apache.wicket.markup.html.form.upload.FileUpload;
+import org.apache.wicket.model.IModel;
 import org.apache.wicket.request.resource.CharSequenceResource;
 import org.apache.wicket.request.resource.IResource;
 
 import org.apache.isis.applib.value.Clob;
+import org.apache.isis.viewer.wicket.model.models.FileUploadModels;
 import org.apache.isis.viewer.wicket.model.models.ScalarModel;
 
 /**
- * Panel for rendering scalars of type {@link Clob Isis' applib.Clob}.
+ * Panel for rendering scalars of type {@link Clob}.
  *
  * <p>
- *    TODO: for now, this only handles CLOBs encoded as UTF-8.
+ *    TODO: for now, this only handles {@link Clob}s encoded as UTF-8.
  *    One option might be to 'guess' the character encoding, eg akin to cpdetector?
  * </p>
  */
@@ -42,20 +43,13 @@ public class IsisClobPanel extends IsisBlobOrClobPanelAbstract<Clob> {
 
     private static final long serialVersionUID = 1L;
 
-    private static final Charset CHARSET = StandardCharsets.UTF_8;
-
     public IsisClobPanel(final String id, final ScalarModel model) {
         super(id, model, Clob.class);
     }
 
     @Override
-    protected Clob getBlobOrClobFrom(final List<FileUpload> fileUploads) {
-        final FileUpload fileUpload = fileUploads.get(0);
-        final String contentType = fileUpload.getContentType();
-        final String clientFileName = fileUpload.getClientFileName();
-        final String str = new String(fileUpload.getBytes(), CHARSET);
-        final Clob blob = new Clob(clientFileName, contentType, str);
-        return blob;
+    protected IModel<List<FileUpload>> fileUploadModel() {
+        return FileUploadModels.clob(scalarModel(), StandardCharsets.UTF_8);
     }
 
     @Override
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/passwd/IsisPasswordPanel.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/passwd/IsisPasswordPanel.java
index 33ba480..c81a7fb 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/passwd/IsisPasswordPanel.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/passwd/IsisPasswordPanel.java
@@ -40,7 +40,7 @@ extends ScalarPanelTextFieldWithValueSemantics<Password> {
     @Override
     protected AbstractTextComponent<Password> createTextField(final String id) {
         return Wkt.passwordFieldWithConverter(
-                id, newTextFieldValueModel(), type, getConverter(getModel()));
+                id, unwrappedModel(), type, getConverter(scalarModel()));
     }
 
 }
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/ResourceLinkVolatile.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/util/ResourceLinkVolatile.java
similarity index 97%
rename from viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/ResourceLinkVolatile.java
rename to viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/util/ResourceLinkVolatile.java
index 02147f3..ee77e44 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/components/scalars/blobclob/ResourceLinkVolatile.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/util/ResourceLinkVolatile.java
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.apache.isis.viewer.wicket.ui.components.scalars.blobclob;
+package org.apache.isis.viewer.wicket.ui.util;
 
 import java.util.UUID;
 
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/util/Wkt.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/util/Wkt.java
index 36e3123..4623bc0 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/util/Wkt.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/util/Wkt.java
@@ -47,6 +47,7 @@ import org.apache.wicket.markup.html.panel.Fragment;
 import org.apache.wicket.markup.repeater.Item;
 import org.apache.wicket.markup.repeater.OddEvenItem;
 import org.apache.wicket.model.IModel;
+import org.apache.wicket.request.resource.IResource;
 import org.apache.wicket.request.resource.ResourceReference;
 import org.apache.wicket.util.convert.IConverter;
 import org.apache.wicket.validation.IValidationError;
@@ -92,6 +93,24 @@ public class Wkt {
         return component;
     }
 
+    // -- ATTRIBUTES
+
+    /**
+     * If any argument is null or empty, does nothing.
+     */
+    public <T extends Component> T attributeAppend(
+            final @Nullable T component,
+            final @Nullable String attributeName,
+            final @Nullable String attributeValue) {
+        if(component==null
+                || _Strings.isEmpty(attributeName)
+                || _Strings.isEmpty(attributeValue)) {
+            return component;
+        }
+        component.add(new AttributeModifier(attributeName, attributeValue));
+        return component;
+    }
+
     // -- BEHAVIOR
 
     public Behavior behaviorOnClick(final SerializableConsumer<AjaxRequestTarget> onClick) {
@@ -373,6 +392,12 @@ public class Wkt {
                 : cssClass.replaceAll("\\.", "-").replaceAll("[^A-Za-z0-9- ]", "").replaceAll("\\s+", "-");
     }
 
+    // -- DOWNLOAD (RESOURCE LINK)
+
+    public ResourceLinkVolatile downloadLinkNoCache(final String id, final IResource resourceModel) {
+        return new ResourceLinkVolatile(id, resourceModel);
+    }
+
     // -- FRAGMENT
 
     /**
@@ -709,4 +734,5 @@ public class Wkt {
         }
     }
 
+
 }