<?xml version="1.0" encoding="utf-8"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><language>en</language><title>Blog posts by Linh Doan Cuu</title> <link>https://world.optimizely.com/blogs/linh-doan-cuu/</link><description></description><ttl>60</ttl><generator>Optimizely World</generator><item> <title>Drag-and-Drop Reordering for Commerce Media Collection in Optimizely Commerce Connect</title>            <link>https://world.optimizely.com/blogs/linh-doan-cuu/dates/2026/8/drag-and-drop-reordering-for-commerce-media-collection-in-optimizely-commerce-connect/</link>            <description>&lt;p&gt;Optimizely Commerce Connect ships a polished asset editor for the &lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMediaCollection&amp;nbsp;&lt;/span&gt;property on catalog entries. It lets editors add, remove, and reorder media assets directly in the edit view &amp;mdash; a solid baseline for most projects. Reordering is done via &quot;Move Up&quot; and &quot;Move Down&quot; buttons in the grid, which is perfectly fine when you have a handful of assets.&lt;/p&gt;
&lt;p&gt;For some projects, though, editors need to manage dozens of images per product &amp;mdash; product shots, lifestyle images, detail crops, downloads &amp;mdash; and clicking a button 30 times to move an asset to the top becomes a real workflow problem. Drag-and-drop row reordering is the natural solution. This post walks through how to add it by extending the built-in Commerce editor rather than replacing it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Property&lt;/h2&gt;
&lt;p&gt;The&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMediaCollection&amp;nbsp;&lt;/span&gt;property is declared on&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;EntryContentBase&amp;nbsp;&lt;/span&gt;and typed as&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ItemCollection&amp;lt;CommerceMedia&amp;gt;&lt;/span&gt;. Each&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMedia&amp;nbsp;&lt;/span&gt;item carries a&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;SortOrder&amp;nbsp;&lt;/span&gt;integer that controls display priority on the front end &amp;mdash; carousels, image galleries, download lists. Whatever order the editor sees in the CMS is what the front end is supposed to render.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[UIHint(&quot;commercemediacollection&quot;)]
public virtual ItemCollection&amp;lt;CommerceMedia&amp;gt; CommerceMediaCollection { get; set; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The built-in editor already renders this as a dgrid with drag-handle affordances &amp;mdash; the visual infrastructure for drag-and-drop is present. Wiring it up for internal row reordering and making sure&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;SortOrder&amp;nbsp;&lt;/span&gt;is written correctly afterward is the implementation work.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Approach: Extend, Don&#39;t Replace&lt;/h2&gt;
&lt;p&gt;Optimizely Commerce Connect Asset Collection&#39;s editor descriptor sets up column definitions, thumbnail formatters, item converters, and the dialog flow for adding assets. Rather than reimplementing all of that, the approach is to register a custom descriptor that runs last, inherits everything the Commerce descriptor configured, and only swaps out the client-side widget class.&lt;/p&gt;
&lt;p&gt;The widget itself extends&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMediaCollectionEditor&amp;nbsp;&lt;/span&gt;and overrides the minimum needed: how the grid&#39;s DnD layer is wired, how sort order is written after a drag, and whether columns are user-sortable.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 1 &amp;mdash; Register a Custom Editor Descriptor&lt;/h2&gt;
&lt;p&gt;Optimizely CMS resolves editor descriptors by&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;(TargetType, UIHint)&lt;/span&gt;&amp;nbsp;pair. The&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMediaCollection&lt;/span&gt;&amp;nbsp;property carries&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;[UIHint(&quot;commercemediacollection&quot;)]&lt;/span&gt;, so the custom descriptor must declare the same UIHint.&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;EditorDescriptorBehavior.PlaceLast&lt;/span&gt;&amp;nbsp;ensures it runs after Commerce&#39;s built-in descriptor, so all Commerce-specific metadata is already applied before we override just the widget class name.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using EPiServer.Commerce.SpecializedProperties;
using EPiServer.Shell.ObjectEditing;
using EPiServer.Shell.ObjectEditing.EditorDescriptors;

[EditorDescriptorRegistration(
    TargetType = typeof(ItemCollection&amp;lt;CommerceMedia&amp;gt;),
    UIHint = &quot;commercemediacollection&quot;,
    EditorDescriptorBehavior = EditorDescriptorBehavior.PlaceLast)]
public class CommerceMediaDndEditorDescriptor : EditorDescriptor
{
    public override void ModifyMetadata(
        ExtendedMetadata metadata,
        IEnumerable&amp;lt;Attribute&amp;gt; attributes)
    {
        base.ModifyMetadata(metadata, attributes);
        metadata.ClientEditingClass = &quot;myproject/editors/CommerceMediaDndEditor&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 2 &amp;mdash; Create a Protected Shell Module&lt;/h2&gt;
&lt;p&gt;The Dojo AMD path&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;myproject/editors/CommerceMediaDndEditor&lt;/span&gt;&amp;nbsp;must resolve to a real file. That means registering a Dojo package called&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;myproject&amp;nbsp;&lt;/span&gt;via a protected shell module.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;modules/_protected/MyProject.Commerce.UI/module.config:&lt;/span&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&amp;gt;
&amp;lt;module name=&quot;MyProject.Commerce.UI&quot; clientResourceRelativePath=&quot;&quot;&amp;gt;
    &amp;lt;dojo&amp;gt;
        &amp;lt;packages&amp;gt;
            &amp;lt;add name=&quot;myproject&quot; location=&quot;ClientResources&quot; /&amp;gt;
        &amp;lt;/packages&amp;gt;
    &amp;lt;/dojo&amp;gt;
    &amp;lt;clientModule&amp;gt;
        &amp;lt;moduleDependencies&amp;gt;
            &amp;lt;add dependency=&quot;CMS&quot; /&amp;gt;
            &amp;lt;add dependency=&quot;Commerce&quot; /&amp;gt;
        &amp;lt;/moduleDependencies&amp;gt;
    &amp;lt;/clientModule&amp;gt;
&amp;lt;/module&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Source file tracking:&lt;/strong&gt;&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;modules/_protected/&amp;nbsp;&lt;/span&gt;is typically gitignored because NuGet restores add-on packages there. Keep your source in a separate tracked directory (e.g.&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ShellModules/_protected/&lt;/span&gt;) and copy it at build time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;ItemGroup&amp;gt;
    &amp;lt;ShellModuleSource Include=&quot;ShellModules\_protected\**\*&quot; /&amp;gt;
    &amp;lt;Content Remove=&quot;ShellModules\_protected\**\*&quot; /&amp;gt;
    &amp;lt;Content Include=&quot;@(ShellModuleSource)&quot;&amp;gt;
        &amp;lt;Link&amp;gt;modules\_protected\%(RecursiveDir)%(FileName)%(Extension)&amp;lt;/Link&amp;gt;
        &amp;lt;CopyToOutputDirectory&amp;gt;PreserveNewest&amp;lt;/CopyToOutputDirectory&amp;gt;
        &amp;lt;CopyToPublishDirectory&amp;gt;PreserveNewest&amp;lt;/CopyToPublishDirectory&amp;gt;
    &amp;lt;/Content&amp;gt;
&amp;lt;/ItemGroup&amp;gt;

&amp;lt;Target Name=&quot;CopyCustomShellModules&quot; BeforeTargets=&quot;Build&quot;&amp;gt;
    &amp;lt;Copy
        SourceFiles=&quot;@(ShellModuleSource)&quot;
        DestinationFolder=&quot;$(MSBuildProjectDirectory)\modules\_protected\%(RecursiveDir)&quot;
        SkipUnchangedFiles=&quot;true&quot; /&amp;gt;
&amp;lt;/Target&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Explicit module registration:&lt;/strong&gt;&amp;nbsp;EPiServer Shell 12.x auto-discovery matches module directories to assemblies by name. A module named&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;MyProject.Commerce.UI&lt;/span&gt;&amp;nbsp;with no correspondingly-named assembly is skipped. Register it explicitly via&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;IConfigurableModule&lt;/span&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.ServiceLocation;
using EPiServer.Shell.Modules;
using Microsoft.Extensions.DependencyInjection;

[InitializableModule]
[ModuleDependency(typeof(EPiServer.Shell.UI.InitializationModule))]
public class CommerceUiModuleRegistration : IConfigurableModule
{
    public void ConfigureContainer(ServiceConfigurationContext context)
    {
        context.Services.Configure&amp;lt;ProtectedModuleOptions&amp;gt;(options =&amp;gt;
        {
            if (options.Items.Any(x =&amp;gt; x.Name == &quot;MyProject.Commerce.UI&quot;))
                return;

            options.Items.Add(new ModuleDetails
            {
                Name = &quot;MyProject.Commerce.UI&quot;
            });
        });
    }

    public void Initialize(InitializationEngine context) { }
    public void Uninitialize(InitializationEngine context) { }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&amp;nbsp;Do not set&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;Assemblies&amp;nbsp;&lt;/span&gt;in&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ModuleDetails&amp;nbsp;&lt;/span&gt;to your main web assembly. This module is purely client-side (JavaScript +&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;module.config&lt;/span&gt;) &amp;mdash; no C# shell controllers. Pointing&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;Assemblies&lt;/span&gt;&amp;nbsp;at the main project assembly causes EPiServer Shell to re-process it under its own application-part rules, which conflicts with ASP.NET Core&#39;s existing registration of that assembly and breaks ViewComponent discovery. Omit&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;Assemblies&amp;nbsp;&lt;/span&gt;for client-only modules.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 3 &amp;mdash; The Custom Dojo Widget&lt;/h2&gt;
&lt;p&gt;The widget has three jobs:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Enable internal row DnD&lt;/strong&gt;&amp;nbsp;&amp;mdash;&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;epi/shell/dnd/Source&lt;/span&gt;&amp;nbsp;(the DnD source class used by the grid) does not self-accept by default when&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;accept&lt;/span&gt;&amp;nbsp;type strings are configured. Commerce media items don&#39;t carry a recognized&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;typeIdentifier&lt;/span&gt;, so the type-matching check fails for same-source drops. Wrapping&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;checkAcceptance&amp;nbsp;&lt;/span&gt;with&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;dojo/aspect&lt;/span&gt;&#39;s&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;around&amp;nbsp;&lt;/span&gt;restores the standard behaviour for internal drags while leaving external drop handling (adding assets from the DAM) completely unchanged.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Write sequential&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;SortOrder&lt;/span&gt;&amp;nbsp;values&lt;/strong&gt;&amp;nbsp;&amp;mdash; After a drag, every item needs a&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;SortOrder&amp;nbsp;&lt;/span&gt;that matches its new visual position. The custom model override handles the move and renumbers all items 1, 2, 3, &amp;hellip; in one atomic update, firing&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;itemsChanged&amp;nbsp;&lt;/span&gt;exactly once with the final correct state.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Disable column header sorting&lt;/strong&gt;&amp;nbsp;&amp;mdash; Clicking a column header in the grid would re-sort rows by that column&#39;s data without saving, creating a mismatch between what the editor sees and what is stored. Marking all columns&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;sortable: false&lt;/span&gt;&amp;nbsp;prevents this.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;define([
  &quot;dojo/_base/declare&quot;,
  &quot;dojo/aspect&quot;,
  &quot;epi-ecf-ui/contentediting/editors/CommerceMediaCollectionEditor&quot;,
  &quot;epi-ecf-ui/contentediting/editors/model/CommerceMediaCollectionEditorModel&quot;,
], function (
  declare,
  aspect,
  CommerceMediaCollectionEditor,
  CommerceMediaCollectionEditorModel,
) {
  // Extended model: moves the item in the array and assigns sequential
  // SortOrder values across all items in a single atomic update.
  var ShiftReorderModel = declare([CommerceMediaCollectionEditorModel], {
    moveItem: function (item, target, before) {
      // Suppress itemsChanged from the first splice (remove)
      this._itemsUnchanged = true;
      var fromIdx = this._itemModels.indexOf(item);
      this._itemModels.splice(fromIdx, 1);

      // Suppress itemsChanged from the second splice (insert)
      this._itemsUnchanged = true;
      var toIdx = this._itemModels.indexOf(target);
      toIdx =
        toIdx === -1
          ? this._itemModels.length // dropped past last row &amp;rarr; append
          : before
            ? toIdx
            : toIdx + 1;
      this._itemModels.splice(toIdx, 0, item);

      // Renumber: SortOrder 1, 2, 3, &amp;hellip; in array order
      this._itemModels.forEach(function (m, i) {
        m.sortOrder = i + 1;
      });

      // Fire once with the final correct state
      this.emit(&quot;itemsChanged&quot;, this.get(&quot;items&quot;));
    },
  });

  return declare([CommerceMediaCollectionEditor], {
    modelType: ShiftReorderModel,

    // Disable column header sorting so grid order always reflects stored order
    _getGridDefinition: function () {
      var columns = this.inherited(arguments);
      for (var col in columns) {
        if (columns[col]) {
          columns[col].sortable = false;
        }
      }
      return columns;
    },

    // Wire internal DnD and restore self-acceptance for same-source drops
    _setupDnD: function () {
      this.inherited(arguments);

      var dndSrc = this.grid.dndSource;
      this.own(
        aspect.around(dndSrc, &quot;checkAcceptance&quot;, function (original) {
          return function (source, nodes) {
            // Allow reordering within the same grid
            if (source === this) {
              return true;
            }
            return original.apply(this, arguments);
          };
        }),
      );
    },
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Place this at&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ShellModules/_protected/MyProject.Commerce.UI/ClientResources/editors/CommerceMediaDndEditor.js&lt;/span&gt;&amp;nbsp;(the MSBuild target copies it to&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;modules/_protected/&lt;/span&gt;&amp;nbsp;at build time).&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;End Result&lt;/h2&gt;
&lt;p&gt;Editors open a catalog entry, switch to the Assets tab, and drag rows to reorder media. The grid updates immediately. On save, each asset&#39;s&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;SortOrder&amp;nbsp;&lt;/span&gt;reflects its position in the grid &amp;mdash; 1 for the first row, 2 for the second, and so on. No separate admin page, no property changes, no base class modifications.&lt;/p&gt;
&lt;p&gt;The solution can be applied to any&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ItemCollection&amp;lt;CommerceMedia&amp;gt;&lt;/span&gt;&amp;nbsp;property in the codebase with the correct UIHint.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;File Checklist&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMediaDndEditorDescriptor.cs&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;Registers the custom widget for&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ItemCollection&amp;lt;CommerceMedia&amp;gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceUiModuleRegistration.cs&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;Registers the shell module explicitly so auto-discovery doesn&#39;t skip it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ShellModules/_protected/MyProject.Commerce.UI/module.config&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;Declares the&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;myproject&amp;nbsp;&lt;/span&gt;Dojo package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ShellModules/_protected/MyProject.Commerce.UI/ClientResources/editors/CommerceMediaDndEditor.js&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;The custom editor widget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;MyProject.csproj&lt;/span&gt;&amp;nbsp;(MSBuild target)&lt;/td&gt;
&lt;td&gt;Copies shell module source to&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;modules/_protected/ &lt;/span&gt;at build time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description>            <guid>https://world.optimizely.com/blogs/linh-doan-cuu/dates/2026/8/drag-and-drop-reordering-for-commerce-media-collection-in-optimizely-commerce-connect/</guid>            <pubDate>Wed, 05 Aug 2026 09:48:55 GMT</pubDate>           <category>Blog post</category></item></channel>
</rss>