Repository: apostrophecms/apostrophe
Pull request #4893: PRO-7380 PRO-7483 Widget live preview
Author: login: boutell
Created: 2025-03-28T00:00:13Z | Merged: 2025-04-08T13:36:27Z

See changelog. For the most interesting results, try the `preview-widgets` branch of `gt-samples` (the apostrophe project is in the `backend` subdirectory). In particular, in the "Box" tab try setting "width" to "custom percentage" and then dragging the slider.

Not available for rich text for now.


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d0f4bc8a1..14bcc964ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## UNRELEASED
 
+### Adds
+
+* To display a live preview on the page as changes are made to widgets, set the `preview: true` option on any widget module. To turn it on for all widgets, you can set it on the `@apostrophecms/widget-type` module, the base class of all widget modules. This works especially well when `range` fields are used to achieve visual effects.
+
 ### Changes
 
 * Improve the Page Manager experience when dragging and dropping pages - the updates happen in background and the UI is not blocked anymore.
@@ -9,6 +13,7 @@
 * Change user's email field type to `email`.
 * Improve media manager experience after uploading images. No additional server requests are made, no broken UI on error.
 * Change reset password form button label to `Reset Password`.
+* Removed overly verbose logging of schema errors in the schema module itself. These are already logged appropriately if they become the actual result of an API call. With this change it becomes possible to catch and discard or mitigate these in some situations without excessive log output.
 * Bumps eslint-config-apostrophe, fix errors and a bunch of warnings.
 
 ### Fixes
diff --git a/modules/@apostrophecms/area/index.js b/modules/@apostrophecms/area/index.js
index b761b5efd8..b31d650fc2 100644
--- a/modules/@apostrophecms/area/index.js
+++ b/modules/@apostrophecms/area/index.js
@@ -37,7 +37,7 @@ module.exports = {
           const type = self.apos.launder.string(req.body.type);
           const _docId = self.apos.launder.id(req.body._docId);
           const field = self.apos.schema.getFieldById(areaFieldId);
-
+          const livePreview = self.apos.launder.boolean(req.body.livePreview);
           if (!field) {
             throw self.apos.error('invalid');
           }
@@ -51,7 +51,14 @@ module.exports = {
             self.warnMissingWidgetType(type);
             throw self.apos.error('invalid');
           }
-          widget = await sanitize(widget);
+          try {
+            widget = await sanitize(widget);
+          } catch (e) {
+            if (livePreview) {
+              return 'aposLivePreviewSchemaNotYetValid';
+            }
+            throw e;
+          }
           widget._edit = true;
           widget._docId = _docId;
           // So that carrying out relationship loading again can yield results
@@ -634,6 +641,7 @@ module.exports = {
         const widgetEditors = {};
         const widgetManagers = {};
         const widgetIsContextual = {};
+        const widgetPreview = {};
         const widgetHasPlaceholder = {};
         const widgetHasInitialModal = {};
         const contextualWidgetDefaultData = {};
@@ -645,6 +653,7 @@ module.exports = {
           widgetEditors[name] = (browserData && browserData.components && browserData.components.widgetEditor) || 'AposWidgetEditor';
           widgetManagers[name] = manager.__meta.name;
           widgetIsContextual[name] = manager.options.contextual;
+          widgetPreview[name] = manager.options.preview;
           widgetHasPlaceholder[name] = manager.options.placeholder;
           widgetHasInitialModal[name] = !manager.options.placeholder && manager.options.initialModal !== false;
           contextualWidgetDefaultData[name] = manager.options.defaultData || {};
@@ -659,6 +668,7 @@ module.exports = {
           widgetIsContextual,
           widgetHasPlaceholder,
           widgetHasInitialModal,
+          widgetPreview,
           contextualWidgetDefaultData,
           widgetManagers,
           action: self.action
diff --git a/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue b/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue
index ed6606d2cb..8c9c8317ec 100644
--- a/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue
+++ b/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue
@@ -197,6 +197,10 @@ export default {
     }
   },
   watch: {
+    // Note: please don't make this a deep watcher as that could cause
+    // issues with live widget preview and also performance, the top level
+    // array will change in situations where a patch API call is actually
+    // needed at this level
     next() {
       if (!this.docId) {
         // For the benefit of AposInputArea which is the
@@ -304,8 +308,8 @@ export default {
         ...this.next.slice(i + 2)
       ];
     },
-    async remove(i) {
-      if (this.docId === window.apos.adminBar.contextId) {
+    async remove(i, { autosave = true } = {}) {
+      if (autosave && (this.docId === window.apos.adminBar.contextId)) {
         apos.bus.$emit('context-edited', {
           $pullAllById: {
             [`@${this.id}.items`]: [ this.next[i]._id ]
@@ -382,13 +386,15 @@ export default {
         const componentName = this.widgetEditorComponent(widget.type);
         apos.area.activeEditor = this;
         apos.bus.$on('apos-refreshing', cancelRefresh);
+        const preview = this.widgetPreview(widget.type, i, false);
         const result = await apos.modal.execute(componentName, {
           modelValue: widget,
           options: this.widgetOptionsByType(widget.type),
           type: widget.type,
           docId: this.docId,
           parentFollowingValues: this.followingValues,
-          meta: this.meta[widget._id]?.aposMeta
+          meta: this.meta[widget._id]?.aposMeta,
+          preview
         });
         apos.area.activeEditor = null;
         apos.bus.$off('apos-refreshing', cancelRefresh);
@@ -433,12 +439,12 @@ export default {
         // actual files, and the reference count will update automatically
       }
     },
-    async update(widget) {
+    async update(widget, { autosave = true } = {}) {
       widget.aposPlaceholder = false;
       if (!widget.metaType) {
         widget.metaType = 'widget';
       }
-      if (this.docId === window.apos.adminBar.contextId) {
+      if (autosave && (this.docId === window.apos.adminBar.contextId)) {
         apos.bus.$emit('context-edited', {
           [`@${widget._id}`]: widget
         });
@@ -489,12 +495,14 @@ export default {
       } else {
         const componentName = this.widgetEditorComponent(name);
         apos.area.activeEditor = this;
+        const preview = this.widgetPreview(name, index, true);
         const widget = await apos.modal.execute(componentName, {
           modelValue: null,
           options: this.widgetOptionsByType(name),
           type: name,
           docId: this.docId,
-          parentFollowingValues: this.followingValues
+          parentFollowingValues: this.followingValues,
+          preview
         });
         apos.area.activeEditor = null;
         if (widget) {
@@ -520,20 +528,22 @@ export default {
     contextualWidgetDefaultData(type) {
       return this.moduleOptions.contextualWidgetDefaultData[type];
     },
-    async insert({ index, widget }) {
+    async insert({
+      index, widget, autosave = true
+    } = {}) {
       if (!widget._id) {
         widget._id = createId();
       }
       if (!widget.metaType) {
         widget.metaType = 'widget';
       }
-      const push = {
-        $each: [ widget ]
-      };
-      if (index < this.next.length) {
-        push.$before = this.next[index]._id;
-      }
-      if (this.docId === window.apos.adminBar.contextId) {
+      if (autosave && (this.docId === window.apos.adminBar.contextId)) {
+        const push = {
+          $each: [ widget ]
+        };
+        if (index < this.next.length) {
+          push.$before = this.next[index]._id;
+        }
         apos.bus.$emit('context-edited', {
           $push: {
             [`@${this.id}.items`]: push
@@ -561,6 +571,15 @@ export default {
     widgetEditorComponent(type) {
       return this.moduleOptions.components.widgetEditors[type];
     },
+    widgetPreview(type, index, create) {
+      return this.moduleOptions.widgetPreview[type]
+        ? {
+          area: this,
+          index,
+          create
+        }
+        : null;
+    },
     // Recursively seek `subObject` within `object`, based on whether
     // its _id matches that of a sub-object of `object`. If found,
     // replace that sub-object with `subObject` and return `true`.
diff --git a/modules/@apostrophecms/area/views/area.html b/modules/@apostrophecms/area/views/area.html
index a90d24e73e..63076cc16e 100644
--- a/modules/@apostrophecms/area/views/area.html
+++ b/modules/@apostrophecms/area/views/area.html
@@ -1,6 +1,5 @@
 {# area needs its own copy of the widget options as #}
 {# JSON, for adding new widgets #}
-{%- set isSingleton = data.options.limit == 1 and data.options.type -%}
 
 <div class="apos-area" {%- if data.canEdit %} data-apos-area-newly-editable data-doc-id='{{ data.area._docId | jsonAttribute({ single: true }) }}' data-field-id='{{ data.field._id | jsonAttribute({ single: true }) }}' data-options='{{ apos.util.omit(data.options, 'area') | jsonAttribute({ single: true }) }}' data='{{ data.area | jsonAttribute({ single: true }) }}' data-choices='{{ data.choices | jsonAttribute({ single: true }) }}'{% endif %}>
   {%- for item in data.area.items -%}
diff --git a/modules/@apostrophecms/image-widget/views/widget.html b/modules/@apostrophecms/image-widget/views/widget.html
index 94da835720..abe0ca2c35 100644
--- a/modules/@apostrophecms/image-widget/views/widget.html
+++ b/modules/@apostrophecms/image-widget/views/widget.html
@@ -1,3 +1,5 @@
+{% set attachment = apos.image.first(data.widget._image) %}
+
 {% if data.widget.aposPlaceholder and data.manager.options.placeholderUrl %}
   <img
     src="{{ data.manager.options.placeholderUrl }}"
@@ -10,7 +12,6 @@
   {% set loadingType = data.options.loadingType or data.manager.options.loadingType %}
   {% set size = data.options.size or data.manager.options.size or 'full' %}
 
-  {% set attachment = apos.image.first(data.widget._image) %}
 
   {% if attachment %}
     <img {% if className %} class="{{ className }}"{% endif %}
diff --git a/modules/@apostrophecms/schema/index.js b/modules/@apostrophecms/schema/index.js
index 7e0324effe..bd5bce34fd 100644
--- a/modules/@apostrophecms/schema/index.js
+++ b/modules/@apostrophecms/schema/index.js
@@ -684,10 +684,6 @@ module.exports = {
           nonVisibleFields
         });
 
-        for (const error of validErrors) {
-          self.apos.util.error(error.stack);
-        }
-
         if (validErrors.length) {
           throw validErrors;
         }
diff --git a/modules/@apostrophecms/video-widget/ui/src/index.js b/modules/@apostrophecms/video-widget/ui/src/index.js
index 5d1e723d52..114fa6b567 100644
--- a/modules/@apostrophecms/video-widget/ui/src/index.js
+++ b/modules/@apostrophecms/video-widget/ui/src/index.js
@@ -1,4 +1,5 @@
 export default () => {
+  apos.oembedCache ||= {};
   apos.util.widgetPlayers['@apostrophecms/video'] = {
     selector: '[data-apos-video-widget]',
     player: function(el) {
@@ -30,15 +31,25 @@ export default () => {
       }
 
       function query(options, callback) {
+        if (Object.hasOwn(apos.oembedCache, options.url)) {
+          return callback(null, apos.oembedCache[options.url]);
+        }
         const opts = {
           qs: {
             url: options.url
           }
         };
-        return apos.http.get('/api/v1/@apostrophecms/oembed/query', opts, callback);
+        return apos.http.get('/api/v1/@apostrophecms/oembed/query', opts, function(err, result) {
+          if (err) {
+            return callback(err);
+          }
+          apos.oembedCache[options.url] = result;
+          return callback(null, result);
+        });
       }
 
       function play(el, result) {
+        // Use aspect-ratio to eliminate the need for any timeout at all
         const shaker = document.createElement('div');
         shaker.innerHTML = result.html;
         const inner = shaker.firstChild;
@@ -49,33 +60,11 @@ export default () => {
         }
         inner.removeAttribute('width');
         inner.removeAttribute('height');
-        el.append(inner);
-        // wait for CSS width to be known
-        setTimeout(function() {
-          // If oembed results include width and height we can get the
-          // video aspect ratio right
-          if (result.width && result.height) {
-            inner.style.width = '100%';
-            resizeVideo(inner);
-            // If we need to initially size the video, also resize it on window
-            // resize.
-            window.addEventListener('resize', resizeHandler);
-          } else {
-            // No, so assume the oembed HTML code is responsive.
-          }
-        }, 0);
-      }
-
-      function resizeVideo(canvasEl) {
-        canvasEl.style.height = ((queryResult.height / queryResult.width) * canvasEl.offsetWidth) + 'px';
-      };
-
-      function resizeHandler() {
-        if (document.contains(el)) {
-          resizeVideo(el.querySelector('[data-apos-video-canvas]'));
-        } else {
-          window.removeEventListener('resize', resizeHandler);
+        if (result.width && result.height) {
+          inner.style.width = '100%';
+          inner.style.aspectRatio = `${queryResult.width} / ${queryResult.height}`;
         }
+        el.append(inner);
       }
 
       function fail(err) {
diff --git a/modules/@apostrophecms/video-widget/views/widget.html b/modules/@apostrophecms/video-widget/views/widget.html
index 1663d7ae4d..b6b23a8902 100644
--- a/modules/@apostrophecms/video-widget/views/widget.html
+++ b/modules/@apostrophecms/video-widget/views/widget.html
@@ -18,10 +18,7 @@
       data-apos-video-widget
       data-apos-video-url={{ data.widget.video.url }}
     >
-      {% if data.widget.video.thumbnail %}
-      <img src="{{ data.widget.video.thumbnail }}" alt="{{ data.widget.video.thumbnail }}"/>
-      {% endif %}
-    </div>
+   </div>
   {% elif data.user %}
     <p {% if data.manager.options.className %} class="{{ data.manager.options.className }} {{ data.manager.options.className }}--error"{% endif %}>No video selected</p>
   {% endif %}
diff --git a/modules/@apostrophecms/widget-type/index.js b/modules/@apostrophecms/widget-type/index.js
index cdf666abdc..edb65b921d 100644
--- a/modules/@apostrophecms/widget-type/index.js
+++ b/modules/@apostrophecms/widget-type/index.js
@@ -58,6 +58,11 @@
 // that information will not be available yet in any asynchronous node.js code.
 // It is the last thing to happen before the actual page template rendering.
 //
+// ### `preview`
+//
+// If true, the image widget is automatically previewed live following changes in the editor modal.
+// Should not be combined with `contextual`.
+//
 // ## Fields
 //
 // You will need to configure the schema fields for your widget using
@@ -420,7 +425,8 @@ module.exports = {
           className: self.options.className,
           components: self.options.components,
           width: self.options.width,
-          origin: self.options.origin
+          origin: self.options.origin,
+          preview: self.options.preview
         });
         return result;
       }
diff --git a/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue b/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
index d127793350..6b6a9d2f14 100644
--- a/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
+++ b/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
@@ -6,6 +6,7 @@
     @inactive="modal.active = false"
     @show-modal="modal.showModal = true"
     @esc="confirmAndCancel"
+    @no-modal="removePreview"
   >
     <template #breadcrumbs>
       <AposModalBreadcrumbs
@@ -110,12 +111,17 @@ export default {
     parentFollowingValues: {
       type: Object,
       default: null
+    },
+    preview: {
+      required: false,
+      type: Object,
+      default: null
+      // if present, has "area", "index" and "create" properties
     }
   },
   emits: [ 'modal-result' ],
   data() {
     const moduleOptions = window.apos.modules[apos.area.widgetManagers[this.type]];
-
     return {
       id: this.modelValue && this.modelValue._id,
       original: null,
@@ -128,7 +134,7 @@ export default {
         active: false,
         type: 'slide',
         width: moduleOptions.width,
-        origin: moduleOptions.origin,
+        origin: this.preview ? guessOrigin(this.preview.area) : moduleOptions.origin,
         showModal: false
       },
       triggerValidation: false
@@ -190,11 +196,14 @@ export default {
         ...defaults,
         ...this.modelValue
       };
-      return;
+    } else {
+      this.original = klona(defaults);
+      this.docFields.data = defaults;
     }
-
-    this.original = klona(defaults);
-    this.docFields.data = defaults;
+    if (!this.id) {
+      this.newId = createId();
+    }
+    this.initPreview();
   },
   methods: {
     updateDocFields(value) {
@@ -204,11 +213,63 @@ export default {
         ...value.data
       };
       this.evaluateConditions();
+      this.updatePreview();
+    },
+    initPreview() {
+      if (!this.preview) {
+        return;
+      }
+      if (this.preview.create) {
+        this.preview.area.insert({
+          index: this.preview.index,
+          widget: this.getPreviewWidgetObject(),
+          autosave: false
+        });
+      } else {
+        // So we can restore it if we cancel
+        this.previewSnapshot = this.getWidgetObject();
+      }
+    },
+    updatePreview() {
+      if (!this.preview) {
+        return;
+      }
+      const now = Date.now();
+      const body = () => {
+        this.lastPreview = now;
+        this.preview.area.update(this.getPreviewWidgetObject(), { autosave: false });
+      };
+      if (this.updatePreviewTimeout) {
+        clearTimeout(this.updatePreviewTimeout);
+      }
+      if (!this.lastPreview || (now - this.lastPreview > 250)) {
+        // If we're still dragging the slider around, refresh every once in a while,
+        // no matter what
+        body();
+      } else {
+        this.updatePreviewTimeout = setTimeout(body, 250);
+      }
+    },
+    removePreview() {
+      if (!this.preview) {
+        return;
+      }
+      if (this.updatePreviewTimeout) {
+        clearTimeout(this.updatePreviewTimeout);
+      }
+      if (this.preview.create) {
+        this.preview.area.remove(this.getPreviewWidgetIndex(), { autosave: false });
+      } else if (!this.saving) {
+        this.preview.area.update(this.previewSnapshot, { autosave: false });
+      }
     },
     async save() {
+      if (this.updatePreviewTimeout) {
+        clearTimeout(this.updatePreviewTimeout);
+      }
       this.triggerValidation = true;
       this.$nextTick(async () => {
-        const widget = klona(this.docFields.data);
+        const widget = this.getWidgetObject();
         if (this.errorCount > 0) {
           this.triggerValidation = false;
           await apos.notify('apostrophe:resolveErrorsBeforeSaving', {
@@ -227,16 +288,42 @@ export default {
           });
           return;
         }
-        if (!widget.type) {
-          widget.type = this.type;
-        }
-        if (!this.id) {
-          widget._id = createId();
-        }
+        this.saving = true;
         this.$emit('modal-result', widget);
         this.modal.showModal = false;
       });
     },
+    getWidgetObject(props = {}) {
+      const widget = klona(this.docFields.data);
+      widget._id = this.id || this.newId;
+      widget.type = this.type;
+      return {
+        ...widget,
+        ...props
+      };
+    },
+    getPreviewWidgetObject() {
+      if (!this.previewWidgetId) {
+        if (this.preview.create) {
+          // Deliberately different from the final widget's id, which will
+          // be added separately and cleanly
+          this.previewWidgetId = createId();
+        } else {
+          this.previewWidgetId = this.id;
+        }
+      }
+      return {
+        ...this.getWidgetObject({
+          _id: this.previewWidgetId
+        }),
+        aposLivePreview: true
+      };
+    },
+    getPreviewWidgetIndex() {
+      return this.preview.area.next.findIndex(item => {
+        return item._id === this.previewWidgetId;
+      });
+    },
     getDefault() {
       return newInstance(this.schema);
     },
@@ -245,4 +332,16 @@ export default {
     }
   }
 };
+
+function guessOrigin(area) {
+  // When we are in live preview mode, use the bounding box of the area to figure out which
+  // side of the screen will least obscure the widget
+  const rect = area.$el.getBoundingClientRect();
+  const cx = (rect.right - rect.left) / 2 + rect.left;
+  if (cx >= (window.innerWidth / 2)) {
+    return 'left';
+  } else {
+    return 'right';
+  }
+}
 </script>
diff --git a/modules/@apostrophecms/widget-type/ui/apos/mixins/AposWidgetMixin.js b/modules/@apostrophecms/widget-type/ui/apos/mixins/AposWidgetMixin.js
index 3021204c32..cc4d0c3980 100644
--- a/modules/@apostrophecms/widget-type/ui/apos/mixins/AposWidgetMixin.js
+++ b/modules/@apostrophecms/widget-type/ui/apos/mixins/AposWidgetMixin.js
@@ -52,21 +52,32 @@ export default {
   methods: {
     async renderContent() {
       apos.bus.$emit('widget-rendering');
+      const {
+        aposLivePreview,
+        ...widget
+      } = this.modelValue;
       const parameters = {
         _docId: this.docId,
-        widget: this.modelValue,
+        widget,
         areaFieldId: this.areaFieldId,
-        type: this.type
+        type: this.type,
+        livePreview: aposLivePreview
       };
       try {
         if (this.rendering && (isEqual(this.rendering.parameters, parameters))) {
           this.rendered = this.rendering.html;
         } else {
-          this.rendered = '...';
-          this.rendered = await apos.http.post(`${apos.area.action}/render-widget?aposEdit=1&aposMode=${this.mode}`, {
+          // Don't use a placeholder here, it causes flickering in live preview mode.
+          // It is better to display the old until we display the new,
+          // we have "busy" for clarity
+          const result = await apos.http.post(`${apos.area.action}/render-widget?aposEdit=1&aposMode=${this.mode}`, {
             busy: true,
             body: parameters
           });
+          //
+          if (result !== 'aposLivePreviewSchemaNotYetValid') {
+            this.rendered = result;
+          }
         }
         // Wait for reactivity to render v-html so that markup is
         // in the DOM before hinting that it might be time to prepare
diff --git a/test/areas.js b/test/areas.js
index 43d376cbfd..af1b9f9d3e 100644
--- a/test/areas.js
+++ b/test/areas.js
@@ -233,15 +233,14 @@ describe('Areas', function() {
   it('renders an area passed to the `renderArea` method', async function () {
     const req = apos.task.getReq();
     firstRendered = await apos.area.renderArea(req, rteArea, areaDocs[0]);
-
     assert(firstRendered);
-    assert.equal(firstRendered, `
+    assert.equal(firstRendered.trim(), `
 <div class="apos-area">
 <div data-rich-text>
   <p>Perhaps its fate that today is the 4th of July, and you will once again be fighting for our freedom, not from tyranny, oppression, or persecution -- but from annihilation.</p><p>We're fighting for our right to live, to exist.</p>
 </div>
 </div>
-`);
+`.trim());
   });
 
   it('returns rendered HTML from the `renderArea` method for a mixed widget area', async function() {
diff --git a/test/widgets.js b/test/widgets.js
index 8005739607..f32b20a84a 100644
--- a/test/widgets.js
+++ b/test/widgets.js
@@ -277,7 +277,6 @@ describe('Widgets', function() {
         placeholderUrlOverride: '/modules/@apostrophecms/my-image-widget/placeholder.webp',
         assertAposPlaceholderTrue(document) {
           const imgNodes = document.querySelectorAll('img');
-
           assert(imgNodes.length === 1);
           assert(imgNodes[0].classList.contains('image-widget-placeholder'));
           assert(imgNodes[0].alt === 'Image placeholder');
@@ -366,7 +365,6 @@ describe('Widgets', function() {
         before(async function() {
           await insertPage(apos, homePath, widgets);
           page = await apos.page.find(req, { slug: '/placeholder-page' }).toObject();
-
           const args = getRenderArgs(req, page);
           result = await apos.modules['placeholder-page'].render(req, 'page', args);
         });
@@ -490,4 +488,5 @@ describe('Widgets', function() {
       });
     });
   });
+
 });


Reviews:
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:01:22Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:01:51Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:02:16Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:03:16Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:05:34Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:06:33Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:07:54Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:08:39Z
user: login: ValJed
body: Mocha tests are red.
Also is there a ticket associated with it? I'm not perfectly clear about steps to test this one.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:36:24Z
user: login: ValJed
body: Tiny things + few questions:
- What about making it behavior by default?
- Would it be possible to not update the view if the schema isn't valid? Otherwise it triggers console errors + the `Unable to render widget` when an input isn't valid (like opening schema).
![image](https://github.com/user-attachments/assets/79c24844-2bd2-42b0-8ba0-ef19afeb2a02)
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:13:29Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:51:45Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:52:31Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:52:50Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:54:17Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:55:03Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:56:54Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:59:33Z
user: login: ValJed
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T07:44:24Z
user: login: ValJed
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T07:55:46Z

Review comments:
diff_hunk: @@ -197,6 +197,10 @@ export default {
     }
   },
   watch: {
+    // Note: please don't make this a deep watcher as that could cause
path: modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue
user: login: boutell
body: No change here, just futureproofing.
created_at: 2025-03-28T00:01:22Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,5 @@
 {# area needs its own copy of the widget options as #}
 {# JSON, for adding new widgets #}
-{%- set isSingleton = data.options.limit == 1 and data.options.type -%}
path: modules/@apostrophecms/area/views/area.html
user: login: boutell
body: Unrelated: dead code removal.
created_at: 2025-03-28T00:01:50Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,6 @@
-{% if data.widget.aposPlaceholder and data.manager.options.placeholderUrl %}
+{% set attachment = apos.image.first(data.widget._image) %}
+
+{% if (data.widget.aposPlaceholder or not attachment) and data.manager.options.placeholderUrl %}
path: modules/@apostrophecms/image-widget/views/widget.html
user: login: boutell
body: Another use case for the placeholder.
created_at: 2025-03-28T00:02:16Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,5 @@
 export default () => {
+  apos.oembedCache ||= {};
path: modules/@apostrophecms/video-widget/ui/src/index.js
user: login: boutell
body: Cache oembed results while we're on the page. Video widget preview implemented in this way will never be perfect, but this reduces the OMG flicker factor.
created_at: 2025-03-28T00:03:16Z
author_association: MEMBER
diff_hunk: @@ -304,8 +308,8 @@ export default {
         ...this.next.slice(i + 2)
       ];
     },
-    async remove(i) {
-      if (this.docId === window.apos.adminBar.contextId) {
+    async remove(i, { autosave = true } = {}) {
path: modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue
user: login: boutell
body: the widget editor modal now calls back to these methods for preview effects and needs a way to say "don't autosave this, I'm going to clean it up later and revert if the user doesn't go through with it."
created_at: 2025-03-28T00:05:34Z
author_association: MEMBER
diff_hunk: @@ -49,33 +60,11 @@ export default () => {
         }
         inner.removeAttribute('width');
         inner.removeAttribute('height');
-        el.append(inner);
path: modules/@apostrophecms/video-widget/ui/src/index.js
user: login: boutell
body: so much code removed by using aspect-ratio (standard since 2021)
created_at: 2025-03-28T00:06:33Z
author_association: MEMBER
diff_hunk: @@ -201,11 +209,64 @@ export default {
         ...value.data
       };
       this.evaluateConditions();
+      this.updatePreview();
+    },
+    initPreview() {
+      if (!this.preview) {
+        return;
+      }
+      if (this.preview.create) {
+        this.preview.area.insert({
+          index: this.preview.index,
+          widget: this.getPreviewWidgetObject(),
+          autosave: false
+        });
+      } else {
+        // So we can restore it if we cancel
+        this.previewSnapshot = this.getWidgetObject();
+      }
+    },
+    updatePreview() {
+      if (!this.preview) {
+        return;
+      }
+      const now = Date.now();
+      const body = () => {
+        this.lastPreview = now;
+        this.preview.area.update(this.getPreviewWidgetObject(), { autosave: false });
+      }
+      if (this.updatePreviewTimeout) {
+        clearTimeout(this.updatePreviewTimeout);
+      }
+      if (!this.lastPreview || (now - this.lastPreview > 250)) {
path: modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
user: login: boutell
body: Simple debouncing is important because we're relying on server rendering.
created_at: 2025-03-28T00:07:54Z
author_association: MEMBER
diff_hunk: @@ -62,7 +62,9 @@ export default {
         if (this.rendering && (isEqual(this.rendering.parameters, parameters))) {
           this.rendered = this.rendering.html;
         } else {
-          this.rendered = '...';
path: modules/@apostrophecms/widget-type/ui/apos/mixins/AposWidgetMixin.js
user: login: boutell
body: In retrospect this was never needed and it causes unnecessary flicker
created_at: 2025-03-28T00:08:39Z
author_association: MEMBER
diff_hunk: @@ -110,12 +111,16 @@ export default {
     parentFollowingValues: {
       type: Object,
       default: null
+    },
+    preview: {
+      required: false,
+      type: Object
+      // if present, has "area", "index" and "create" properties
path: modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
user: login: ValJed
body: You need a default value to avoid eslint warning: `vue/require-default-prop`
created_at: 2025-04-07T14:00:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -190,11 +195,14 @@ export default {
         ...defaults,
         ...this.modelValue
       };
-      return;
+    } else {
+      this.original = klona(defaults);
+      this.docFields.data = defaults;
     }
-
-    this.original = klona(defaults);
-    this.docFields.data = defaults;
+    if (!this.id) {
+      this.newId = createId();
path: modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
user: login: ValJed
body: Good practice to instantiate all data inside `data` block even with a null value or whatever.
Something like that would be clear, and we would use only newId:
```javascript
return {
   ...
    newId: this.id || createId()
}
```
created_at: 2025-04-07T14:06:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -227,16 +287,39 @@ export default {
           });
           return;
         }
-        if (!widget.type) {
-          widget.type = this.type;
-        }
-        if (!this.id) {
-          widget._id = createId();
-        }
+        this.saving = true;
path: modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
user: login: ValJed
body: Same, would be nice to instantiate this inside data.
created_at: 2025-04-07T14:11:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -190,11 +195,14 @@ export default {
         ...defaults,
         ...this.modelValue
       };
-      return;
+    } else {
+      this.original = klona(defaults);
+      this.docFields.data = defaults;
     }
-
-    this.original = klona(defaults);
-    this.docFields.data = defaults;
+    if (!this.id) {
+      this.newId = createId();
path: modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
user: login: boutell
body: data is reactive, newId does not need to be reactive, so this would be unnecessary performance overhead.
created_at: 2025-04-07T17:51:45Z
author_association: MEMBER
diff_hunk: @@ -190,11 +195,14 @@ export default {
         ...defaults,
         ...this.modelValue
       };
-      return;
+    } else {
+      this.original = klona(defaults);
+      this.docFields.data = defaults;
     }
-
-    this.original = klona(defaults);
-    this.docFields.data = defaults;
+    if (!this.id) {
+      this.newId = createId();
path: modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
user: login: boutell
body: (Because of the avoidable overhead, I don't agree it is a best practice to use data for things that are not reactive, although I would not reject a PR for doing it.)
created_at: 2025-04-07T17:52:31Z
author_association: MEMBER
diff_hunk: @@ -227,16 +287,39 @@ export default {
           });
           return;
         }
-        if (!widget.type) {
-          widget.type = this.type;
-        }
-        if (!this.id) {
-          widget._id = createId();
-        }
+        this.saving = true;
path: modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
user: login: boutell
body: Same issue, it doesn't need the reactivity.
created_at: 2025-04-07T17:52:50Z
author_association: MEMBER
diff_hunk: @@ -37,7 +37,7 @@ module.exports = {
           const type = self.apos.launder.string(req.body.type);
           const _docId = self.apos.launder.id(req.body._docId);
           const field = self.apos.schema.getFieldById(areaFieldId);
-
+          const livePreview = self.apos.launder.boolean(req.body.livePreview);
path: modules/@apostrophecms/area/index.js
user: login: boutell
body: So we can decide how to handle an error.
created_at: 2025-04-07T17:54:17Z
author_association: MEMBER
diff_hunk: @@ -51,7 +51,14 @@ module.exports = {
             self.warnMissingWidgetType(type);
             throw self.apos.error('invalid');
           }
-          widget = await sanitize(widget);
+          try {
+            widget = await sanitize(widget);
+          } catch (e) {
+            if (livePreview) {
+              return 'aposLivePreviewSchemaNotYetValid';
path: modules/@apostrophecms/area/index.js
user: login: boutell
body: The result is HTML, not JSON, so we return a special string to avoid a status code that shows up as an "error" in the browser console. When previewing an error from sanitize is sometimes normal, so we return this code to indicate it's OK to do nothing.
created_at: 2025-04-07T17:55:03Z
author_association: MEMBER
diff_hunk: @@ -684,10 +684,6 @@ module.exports = {
           nonVisibleFields
         });
 
-        for (const error of validErrors) {
path: modules/@apostrophecms/schema/index.js
user: login: boutell
body: See changelog — this has been the cause of a lot of verbose double-logging. We shouldn't log an error unless it is the ultimate result. At least not with util.error. At some point we might revisit the question of how to optionally enable a super-verbose mode and then this would be more appropriate.
created_at: 2025-04-07T17:56:54Z
author_association: MEMBER
diff_hunk: @@ -52,21 +52,32 @@ export default {
   methods: {
     async renderContent() {
       apos.bus.$emit('widget-rendering');
+      const {
+        aposLivePreview,
+        ...widget
+      } = this.modelValue;
       const parameters = {
         _docId: this.docId,
-        widget: this.modelValue,
+        widget,
         areaFieldId: this.areaFieldId,
-        type: this.type
+        type: this.type,
+        livePreview: aposLivePreview
       };
       try {
         if (this.rendering && (isEqual(this.rendering.parameters, parameters))) {
           this.rendered = this.rendering.html;
         } else {
-          this.rendered = '...';
-          this.rendered = await apos.http.post(`${apos.area.action}/render-widget?aposEdit=1&aposMode=${this.mode}`, {
+          // Don't use a placeholder here, it causes flickering in live preview mode.
+          // It is better to display the old until we display the new,
+          // we have "busy" for clarity
+          const result = await apos.http.post(`${apos.area.action}/render-widget?aposEdit=1&aposMode=${this.mode}`, {
             busy: true,
             body: parameters
           });
+          //
+          if (result !== 'aposLivePreviewSchemaNotYetValid') {
path: modules/@apostrophecms/widget-type/ui/apos/mixins/AposWidgetMixin.js
user: login: boutell
body: If we are previewing and the schema isn't valid at the moment according to the server, we have nothing to do here, leave the previous placeholder or preview alone.
created_at: 2025-04-07T17:59:33Z
author_association: MEMBER
diff_hunk: @@ -190,11 +195,14 @@ export default {
         ...defaults,
         ...this.modelValue
       };
-      return;
+    } else {
+      this.original = klona(defaults);
+      this.docFields.data = defaults;
     }
-
-    this.original = klona(defaults);
-    this.docFields.data = defaults;
+    if (!this.id) {
+      this.newId = createId();
path: modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue
user: login: ValJed
body: I'm not sure this is the kind of performance overhead we should worry about.
I'm saying bad practice because I think it's a non document behavior so might lead to unexpected behavior, also think it's a bit less readable but I'm ok.
created_at: 2025-04-08T07:44:24Z
author_association: CONTRIBUTOR

PR comments:
user: login: boutell
created_at: 2025-03-28T00:09:22Z
body: A draft until I write cypress tests
author_association: MEMBER
user: login: boutell
created_at: 2025-04-03T14:10:03Z
body: Not a draft anymore, cypress tests might be deferred or written by someone else. Please do review.
author_association: MEMBER
user: login: boutell
created_at: 2025-04-03T14:14:20Z
body: I pushed up fixes for the lint error. The lint warnings seem problematic and unrelated, I merged main and I have a ton of them for files that I didn't touch.
author_association: MEMBER
user: login: linear[bot]
created_at: 2025-04-07T18:42:30Z
body: <p><a href="https://linear.app/apostrophecms/issue/PRO-7380/widget-live-preview-as-an-editor-i-can-see-the-impact-of-my-changes">PRO-7380 Widget live preview: as an editor I can see the impact of my changes live on the page when editing in the widget editor modal</a></p>
<p><a href="https://linear.app/apostrophecms/issue/PRO-7483/test-the-live-widget-preview-with-a-more-complex-custom-widget-to-see">PRO-7483 Test the Live Widget Preview with a more complex custom widget to see how it does as is and what might be needed.</a></p>
author_association: NONE