<?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>Cheapskate AI Translator: Because Translation Should Not Require a Second Mortgage</title>            <link>https://world.optimizely.com/blogs/linh-doan-cuu/dates/2026/10/cheapskate-ai-translator-because-translation-should-not-require-a-second-mortgage/</link>            <description>&lt;p&gt;There is a particular kind of software project that begins with a detailed architecture document, a product roadmap, and a carefully assembled budget.&lt;/p&gt;
&lt;p&gt;This is not that kind of project.&lt;/p&gt;
&lt;p&gt;This project began with a simpler question:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Can I create language versions in Optimizely CMS without paying more for an LLM than I absolutely have to?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The answer is now an open source add-on: &lt;strong&gt;Cheapskate.AITranslator&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;You can find the source code on &lt;em&gt;&lt;a href=&quot;https://github.com/birgitzprinz/Cheapskate.AITranslator&quot;&gt;GitHub&lt;/a&gt; &lt;/em&gt;and install the published package from the&lt;em&gt; &lt;a href=&quot;https://nuget.optimizely.com/packages/cheapskate.aitranslator/&quot;&gt;Optimizely NuGet feed&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;The honest motivation&lt;/h2&gt;
&lt;p&gt;I am a cheapskate.&lt;/p&gt;
&lt;p&gt;Not in the charming, coupon-clipping sense. In the practical engineering sense. If a free tier, a generous quota, or a small amount of careful request batching can do the job, I would prefer that to adding another monthly AI bill to the household budget.&lt;/p&gt;
&lt;p&gt;The goal is not to avoid paying for good services forever. The goal is to pay as little as I can get away with while still delivering useful translations and a maintainable CMS workflow.&lt;/p&gt;
&lt;p&gt;That means the add-on is designed around choice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Google Gemini-compatible chat completions&lt;/li&gt;
&lt;li&gt;GroqCloud-compatible chat completions&lt;/li&gt;
&lt;li&gt;Microsoft Azure Translator&lt;/li&gt;
&lt;li&gt;DeepL API Free&lt;/li&gt;
&lt;li&gt;Custom OpenAI-compatible providers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Some providers offer free tiers, some offer free quotas, and some are inexpensive enough to be useful when a free option is not appropriate. The add-on does not assume that one provider, one pricing model, or one API will remain the best choice forever.&lt;/p&gt;
&lt;h2&gt;What the add-on does&lt;/h2&gt;
&lt;p&gt;Optimizely content editors can create draft language versions from the editor and choose whether to translate them with AI after the drafts are created.&lt;/p&gt;
&lt;p&gt;The add-on can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create all missing language branches or one selected target language&lt;/li&gt;
&lt;li&gt;Keep the new versions as drafts rather than publishing them&lt;/li&gt;
&lt;li&gt;Optionally follow linked content in &lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ContentArea&lt;/span&gt; properties&lt;/li&gt;
&lt;li&gt;Translate language-specific text and Rich Text content&lt;/li&gt;
&lt;li&gt;Preserve HTML structure while translating visible text&lt;/li&gt;
&lt;li&gt;Run translation in the background after draft creation&lt;/li&gt;
&lt;li&gt;Use different providers through one CMS settings screen&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The workflow is intentionally modest. It creates drafts, starts translation work, and reports the job status. It does not pretend that an automated translation should bypass editorial review.&lt;/p&gt;
&lt;p&gt;That last part is important. A translation engine can be very good and still occasionally produce something that makes a human editor stare at the screen for a while. Drafts are a useful place for that staring to happen.&lt;/p&gt;
&lt;h2&gt;The small trick that saves API calls&lt;/h2&gt;
&lt;p&gt;The add-on can combine multiple translatable properties into one JSON request instead of making one API request per property.&lt;/p&gt;
&lt;p&gt;Without batching, a page with a title, teaser, body, call to action, and several blocks can turn into a small parade of API calls. That is inefficient, costs more, and can hit provider rate limits faster than necessary.&lt;/p&gt;
&lt;p&gt;With batching enabled, the worker groups translation entries into bounded requests according to configured input and output token limits. The provider receives a structured payload, translates the values, and returns the translated entries for the background worker to write back to the draft.&lt;/p&gt;
&lt;p&gt;In plain English:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Fewer trips to the API means fewer opportunities for the API to say, &quot;Please slow down.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Batching does not remove provider limits, and it does not make an unlimited service out of a limited one. It simply uses the available quota more sensibly. The request-size limits remain configurable because different providers have different capabilities and pricing rules.&lt;/p&gt;
&lt;h2&gt;Why support several providers?&lt;/h2&gt;
&lt;p&gt;Because depending on one provider is an excellent way to discover that provider&#39;s outage page at the worst possible moment.&lt;/p&gt;
&lt;p&gt;More seriously, different projects have different constraints:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A project may already have an Azure subscription and prefer Azure Translator.&lt;/li&gt;
&lt;li&gt;A developer may want to use a free Gemini or GroqCloud quota.&lt;/li&gt;
&lt;li&gt;A team may prefer DeepL for a particular language pair.&lt;/li&gt;
&lt;li&gt;An organization may have its own OpenAI-compatible gateway.&lt;/li&gt;
&lt;li&gt;A provider&#39;s model, pricing, quota, or terms may change.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The add-on keeps the CMS workflow stable while allowing the translation backend to change. Provider credentials and options live in CMS settings rather than in source control, and the custom provider option gives developers room to connect a compatible internal service.&lt;/p&gt;
&lt;h2&gt;This is not Optimizely Language Manager&lt;/h2&gt;
&lt;p&gt;A very important clarification: &lt;strong&gt;Cheapskate.AITranslator is not intended to replace Optimizely&#39;s official Language Manager add-on.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Language Manager is the official product for teams that need its supported translation-management workflow, integrations, governance, and product support. This add-on has a narrower and more developer-oriented purpose.&lt;/p&gt;
&lt;p&gt;Cheapskate.AITranslator brings the implementation into the developer&#39;s control. The source code is available, the behavior can be inspected, and the add-on can be customized for a particular Optimizely CMS solution.&lt;/p&gt;
&lt;p&gt;That may mean changing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Which content properties are translated&lt;/li&gt;
&lt;li&gt;How linked content is traversed&lt;/li&gt;
&lt;li&gt;How prompts are written&lt;/li&gt;
&lt;li&gt;Which provider is called&lt;/li&gt;
&lt;li&gt;How batches are sized&lt;/li&gt;
&lt;li&gt;How translation jobs are queued or reported&lt;/li&gt;
&lt;li&gt;Which editorial safeguards are required&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your project needs an officially supported translation-management product, use Language Manager. If your project needs a small, inspectable starting point that your team can adapt, this add-on may be useful.&lt;/p&gt;
&lt;p&gt;It is not a product-versus-product argument. It is a choice between a supported official solution and a source-controlled developer tool with a smaller scope.&lt;/p&gt;
&lt;h2&gt;Open source is the point&lt;/h2&gt;
&lt;p&gt;The most valuable part of this add-on is not the skate icon or the settings page. It is the ability to see what happens between clicking &lt;strong&gt;Create Language Versions&lt;/strong&gt; and saving a translated draft.&lt;/p&gt;
&lt;p&gt;Developers can review the code, change the translation rules, add provider behavior, adjust batching, and fit the workflow to their own content model. The add-on is deliberately not a sealed box that asks everyone to accept the same assumptions.&lt;/p&gt;
&lt;p&gt;That also means responsibility comes with the freedom:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Review generated translations before publishing.&lt;/li&gt;
&lt;li&gt;Protect provider credentials.&lt;/li&gt;
&lt;li&gt;Check provider terms and quotas.&lt;/li&gt;
&lt;li&gt;Test prompts and content types in a non-production environment.&lt;/li&gt;
&lt;li&gt;Keep the add-on aligned with the CMS version used by the project.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cheap is good. Cheap and mysterious is less good.&lt;/p&gt;
&lt;h2&gt;Getting started&lt;/h2&gt;
&lt;p&gt;The project targets Optimizely CMS 13 and .NET 10 on the &lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;main&lt;/span&gt; branch. The legacy CMS 12 implementation remains on the&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CMS-12-base&lt;/span&gt; branch.&lt;/p&gt;
&lt;p&gt;Install the CMS 13 package with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-powershell&quot;&gt;dotnet add .\YourCmsProject.csproj package Cheapskate.AITranslator --version 2.0.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then configure a provider under &lt;strong&gt;Settings &amp;gt; AI Translator&lt;/strong&gt;, open localizable content in the editor, and use the &lt;strong&gt;Create Language Versions&lt;/strong&gt; command.&lt;/p&gt;
&lt;p&gt;The resulting versions are drafts. The AI translation runs in the background when enabled, and the editor remains responsible for the final content review.&lt;/p&gt;
&lt;h2&gt;Final accounting&lt;/h2&gt;
&lt;p&gt;The philosophy is simple:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Use the provider that fits the project.&lt;/li&gt;
&lt;li&gt;Use free quotas where they are enough.&lt;/li&gt;
&lt;li&gt;Batch requests so the quota lasts longer.&lt;/li&gt;
&lt;li&gt;Keep the workflow in developer-controlled source code.&lt;/li&gt;
&lt;li&gt;Spend money when spending money is actually justified.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That is the entire cheapskate strategy. It is not glamorous, but neither is discovering a surprisingly large AI invoice after a bulk translation job.&lt;/p&gt;
&lt;p&gt;Cheapskate.AITranslator is open source under the MIT license: a small Optimizely add-on for teams that want draft language creation, optional AI translation, multiple provider choices, and a little more control over where the money goes.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/linh-doan-cuu/dates/2026/10/cheapskate-ai-translator-because-translation-should-not-require-a-second-mortgage/</guid>            <pubDate>Thu, 15 Oct 2026 08:06:38 GMT</pubDate>           <category>Blog post</category></item><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>