Linh Doan Cuu
Aug 5, 2026
visibility 24
star star star star star
(0 votes)

Drag-and-Drop Reordering for Commerce Media Collection in Optimizely Commerce Connect

Optimizely Commerce Connect ships a polished asset editor for the CommerceMediaCollection property on catalog entries. It lets editors add, remove, and reorder media assets directly in the edit view — a solid baseline for most projects. Reordering is done via "Move Up" and "Move Down" buttons in the grid, which is perfectly fine when you have a handful of assets.

For some projects, though, editors need to manage dozens of images per product — product shots, lifestyle images, detail crops, downloads — 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.


The Property

The CommerceMediaCollection property is declared on EntryContentBase and typed as ItemCollection<CommerceMedia>. Each CommerceMedia item carries a SortOrder integer that controls display priority on the front end — carousels, image galleries, download lists. Whatever order the editor sees in the CMS is what the front end is supposed to render.

[UIHint("commercemediacollection")]
public virtual ItemCollection<CommerceMedia> CommerceMediaCollection { get; set; }

The built-in editor already renders this as a dgrid with drag-handle affordances — the visual infrastructure for drag-and-drop is present. Wiring it up for internal row reordering and making sure SortOrder is written correctly afterward is the implementation work.


Approach: Extend, Don't Replace

Optimizely Commerce Connect Asset Collection'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.

The widget itself extends CommerceMediaCollectionEditor and overrides the minimum needed: how the grid's DnD layer is wired, how sort order is written after a drag, and whether columns are user-sortable.


Step 1 — Register a Custom Editor Descriptor

Optimizely CMS resolves editor descriptors by (TargetType, UIHint) pair. The CommerceMediaCollection property carries [UIHint("commercemediacollection")], so the custom descriptor must declare the same UIHint. EditorDescriptorBehavior.PlaceLast ensures it runs after Commerce's built-in descriptor, so all Commerce-specific metadata is already applied before we override just the widget class name.

using EPiServer.Commerce.SpecializedProperties;
using EPiServer.Shell.ObjectEditing;
using EPiServer.Shell.ObjectEditing.EditorDescriptors;

[EditorDescriptorRegistration(
    TargetType = typeof(ItemCollection<CommerceMedia>),
    UIHint = "commercemediacollection",
    EditorDescriptorBehavior = EditorDescriptorBehavior.PlaceLast)]
public class CommerceMediaDndEditorDescriptor : EditorDescriptor
{
    public override void ModifyMetadata(
        ExtendedMetadata metadata,
        IEnumerable<Attribute> attributes)
    {
        base.ModifyMetadata(metadata, attributes);
        metadata.ClientEditingClass = "myproject/editors/CommerceMediaDndEditor";
    }
}

Step 2 — Create a Protected Shell Module

The Dojo AMD path myproject/editors/CommerceMediaDndEditor must resolve to a real file. That means registering a Dojo package called myproject via a protected shell module.

modules/_protected/MyProject.Commerce.UI/module.config:

<?xml version="1.0" encoding="utf-8"?>
<module name="MyProject.Commerce.UI" clientResourceRelativePath="">
    <dojo>
        <packages>
            <add name="myproject" location="ClientResources" />
        </packages>
    </dojo>
    <clientModule>
        <moduleDependencies>
            <add dependency="CMS" />
            <add dependency="Commerce" />
        </moduleDependencies>
    </clientModule>
</module>

Source file tracking: modules/_protected/ is typically gitignored because NuGet restores add-on packages there. Keep your source in a separate tracked directory (e.g. ShellModules/_protected/) and copy it at build time:

<ItemGroup>
    <ShellModuleSource Include="ShellModules\_protected\**\*" />
    <Content Remove="ShellModules\_protected\**\*" />
    <Content Include="@(ShellModuleSource)">
        <Link>modules\_protected\%(RecursiveDir)%(FileName)%(Extension)</Link>
        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
        <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
    </Content>
</ItemGroup>

<Target Name="CopyCustomShellModules" BeforeTargets="Build">
    <Copy
        SourceFiles="@(ShellModuleSource)"
        DestinationFolder="$(MSBuildProjectDirectory)\modules\_protected\%(RecursiveDir)"
        SkipUnchangedFiles="true" />
</Target>

Explicit module registration: EPiServer Shell 12.x auto-discovery matches module directories to assemblies by name. A module named MyProject.Commerce.UI with no correspondingly-named assembly is skipped. Register it explicitly via IConfigurableModule:

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<ProtectedModuleOptions>(options =>
        {
            if (options.Items.Any(x => x.Name == "MyProject.Commerce.UI"))
                return;

            options.Items.Add(new ModuleDetails
            {
                Name = "MyProject.Commerce.UI"
            });
        });
    }

    public void Initialize(InitializationEngine context) { }
    public void Uninitialize(InitializationEngine context) { }
}

Note: Do not set Assemblies in ModuleDetails to your main web assembly. This module is purely client-side (JavaScript + module.config) — no C# shell controllers. Pointing Assemblies at the main project assembly causes EPiServer Shell to re-process it under its own application-part rules, which conflicts with ASP.NET Core's existing registration of that assembly and breaks ViewComponent discovery. Omit Assemblies for client-only modules.


Step 3 — The Custom Dojo Widget

The widget has three jobs:

  1. Enable internal row DnD — epi/shell/dnd/Source (the DnD source class used by the grid) does not self-accept by default when accept type strings are configured. Commerce media items don't carry a recognized typeIdentifier, so the type-matching check fails for same-source drops. Wrapping checkAcceptance with dojo/aspect's around restores the standard behaviour for internal drags while leaving external drop handling (adding assets from the DAM) completely unchanged.

  2. Write sequential SortOrder values — After a drag, every item needs a SortOrder that matches its new visual position. The custom model override handles the move and renumbers all items 1, 2, 3, … in one atomic update, firing itemsChanged exactly once with the final correct state.

  3. Disable column header sorting — Clicking a column header in the grid would re-sort rows by that column's data without saving, creating a mismatch between what the editor sees and what is stored. Marking all columns sortable: false prevents this.

define([
  "dojo/_base/declare",
  "dojo/aspect",
  "epi-ecf-ui/contentediting/editors/CommerceMediaCollectionEditor",
  "epi-ecf-ui/contentediting/editors/model/CommerceMediaCollectionEditorModel",
], 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 → append
          : before
            ? toIdx
            : toIdx + 1;
      this._itemModels.splice(toIdx, 0, item);

      // Renumber: SortOrder 1, 2, 3, … in array order
      this._itemModels.forEach(function (m, i) {
        m.sortOrder = i + 1;
      });

      // Fire once with the final correct state
      this.emit("itemsChanged", this.get("items"));
    },
  });

  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, "checkAcceptance", function (original) {
          return function (source, nodes) {
            // Allow reordering within the same grid
            if (source === this) {
              return true;
            }
            return original.apply(this, arguments);
          };
        }),
      );
    },
  });
});

Place this at ShellModules/_protected/MyProject.Commerce.UI/ClientResources/editors/CommerceMediaDndEditor.js (the MSBuild target copies it to modules/_protected/ at build time).


End Result

Editors open a catalog entry, switch to the Assets tab, and drag rows to reorder media. The grid updates immediately. On save, each asset's SortOrder reflects its position in the grid — 1 for the first row, 2 for the second, and so on. No separate admin page, no property changes, no base class modifications.

The solution can be applied to any ItemCollection<CommerceMedia> property in the codebase with the correct UIHint.


File Checklist

File What it does
CommerceMediaDndEditorDescriptor.cs Registers the custom widget for ItemCollection<CommerceMedia>
CommerceUiModuleRegistration.cs Registers the shell module explicitly so auto-discovery doesn't skip it
ShellModules/_protected/MyProject.Commerce.UI/module.config Declares the myproject Dojo package
ShellModules/_protected/MyProject.Commerce.UI/ClientResources/editors/CommerceMediaDndEditor.js The custom editor widget
MyProject.csproj (MSBuild target) Copies shell module source to modules/_protected/ at build time
Aug 05, 2026

Comments

error Please login to comment.
Latest blogs
Order tabs with drag and drop V2

I earlier did a very simple Blazor version to be able to sort tabs with drag and drop. I wanted to update it a bit and also display how the tabs ar...

Per Nergård (MVP) | Aug 4, 2026

Optimizely : Missing Language Manager Gadget Fix in CMS 12 After Upgrading to .NET 10

Recently, while upgrading an Optimizely CMS solution to .NET 10, we came across an issue where the Language Manager gadget completely disappeared...

Madhu | Aug 2, 2026 |

Flat or Nested? Weighing Your Content Modeling Options in Optimizely SaaS CMS

When building a headless site on Optimizely SaaS CMS, one of the earliest and most critical design milestones your team will face is defining your...

Vipin Banka | Jul 31, 2026