Search code examples
typescriptalpine.jssortablejs

DOM not updating in x-for loop with AlpineJS when data is sorted


I have an AlpineJS UI which allows an editor to add style rules or groups of style rules (it's not really important what these are but I've kept the concept so as to differentiate from a more simple application).

After adding rules and rule groups you can sort them by dragging/dropping. It uses SortableJS for this.

The rules created are output in JSON format so they can be copied/pasted into a third-party application.

<div class="configurator" x-data="ruleConfigurator()" x-init="init()">

  <template x-for="(rule, index) in rules" :key="index">
    <div class="rule-wrapper">
      <template x-if="rule.type === 'single'">
        <div class="rule rule--single">
          <div class="rule__cell rule__cell--title">
            <input type="text" placeholder="e.g. Heading 1" x-model="rule.title" @input="updateOutput">
          </div>
          <div class="rule__cell rule__cell--type">
            <select x-model="rule.category" @change="updateOutput">
              <option value="block">Block</option>
              <option value="inline">Inline</option>
              <option value="selector">Selector</option>
            </select>
          </div>
          <div class="rule__cell rule__cell--selector">
            <input type="text" placeholder="e.g. h1" x-model="rule.selector" @input="updateOutput">
          </div>
          <div class="rule__cell rule__cell--classes">
            <input type="text" placeholder="e.g. heading-primary" x-model="rule.classes" @input="updateOutput">
          </div>
          <button class="rule__delete" @click="removeRule(index)">X</button>
          <button class="rule__sort">Sort</button>
        </div>
      </template>
      <template x-if="rule.type === 'group'">
        <div class="rule rule--group">
          <div class="rule__cell rule__cell--group-title">
            <input type="text" placeholder="e.g. Headings" x-model="rule.title" @input="updateOutput">
          </div>

          <template x-for="(groupRule, groupRuleIndex) in rule.groupRules" :key="groupRuleIndex">
            <div class="rule__cell rule__cell--rules">
              <div class="rule rule--single">

                <div class="rule__cell rule__cell--title">
                  <input type="text" placeholder="title" x-model="groupRule.title" @input="updateOutput">
                </div>
                <div class="rule__cell rule__cell--type">
                  <select x-model="groupRule.category" @change="updateOutput">
                    <option value="block">Block</option>
                    <option value="inline">Inline</option>
                    <option value="selector">Selector</option>
                  </select>
                </div>
                <div class="rule__cell rule__cell--selector">
                  <input type="text" placeholder="selector" x-model="groupRule.selector" @input="updateOutput">
                </div>
                <div class="rule__cell rule__cell--classes">
                  <input type="text" placeholder="classes" x-model="groupRule.classes" @input="updateOutput">
                </div>
                <button class="rule__delete" @click="removeGroupRule(index, groupRuleIndex)">X</button>

              </div>
            </div>
          </template>
          <div class="add-rules">
            <button class="add-rules__btn btn js-add-style-to-group" @click="addRuleToGroup(index)">Add Style To Group</button>
          </div>

          <button class="rule__delete" @click="removeRule(index)">X</button>
          <button class="rule__sort">Sort</button>
  
        </div>
  
      </template>
    </div>
  </template>

  <div class="add-rules">
    <button @click="addRule('single')" class="add-rules__btn btn">Add Single Style</button>
    <button @click="addRule('group')" class="add-rules__btn btn">Add Style Group</button>  
  </div>

  <div class="output">
    <p>Output:</p>
    <textarea readonly x-text="output" class="output-ui__textarea js-output"></textarea>
  </div>
  
</div>

  </div>
import Alpine from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';
import Sortable, { SortableEvent } from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';

window.Alpine = Alpine;

interface BaseRule {
  title: string;
  category: 'block' | 'inline' | 'selector';
  selector: string;
  classes: string;
}

interface SingleRule extends BaseRule {
  type: 'single';
}

interface GroupRule extends BaseRule {}

interface Group extends BaseRule {
  type: 'group';
  groupRules: GroupRule[];
}

type Rule = SingleRule | Group;

function ruleConfigurator() {
  return {
    rules: [] as Rule[],
    output: '',

    init() {
      this.initSortable();
      this.updateOutput();
    },

    initSortable() {
      const rulesContainer = document.querySelector('.configurator') as HTMLElement;
      Sortable.create(rulesContainer, {
        handle: '.rule__sort',
        onEnd: (event: SortableEvent) => {
          console.log(event);
          const movedRule = this.rules.splice(event.oldIndex!, 1)[0];
          this.rules.splice(event.newIndex!, 0, movedRule);
          this.updateOutput();
        }
      });
    },

    addRule(type: 'single' | 'group') {
      if (type === 'single') {
        this.rules.push({
          type: 'single',
          title: '',
          category: 'block',
          selector: '',
          classes: ''
        } as SingleRule);
      } else {
        this.rules.push({
          type: 'group',
          title: '',
          category: 'block',
          selector: '',
          classes: '',
          groupRules: [
            {
              title: '',
              category: 'block',
              selector: '',
              classes: ''
            }
          ]
        } as Group);
      }
      this.updateOutput();
    },

    addRuleToGroup(ruleIndex: number) {
      const rule = this.rules[ruleIndex];
      if (rule.type === 'group') {
        rule.groupRules.push({
          title: '',
          category: 'block',
          selector: '',
          classes: ''
        });
        this.updateOutput();
      }
    },

    removeRule(index: number) {
      this.rules.splice(index, 1);
      this.updateOutput();
    },

    removeGroupRule(ruleIndex: number, groupRuleIndex: number) {
      const rule = this.rules[ruleIndex];
      if (rule.type === 'group') {
        rule.groupRules.splice(groupRuleIndex, 1);
        this.updateOutput();
      }
    },

    updateOutput() {
      this.output = JSON.stringify(this.rules, null, 2);
    }
  };
}

document.addEventListener('alpine:init', () => {
  Alpine.data('ruleConfigurator', ruleConfigurator);
});

Alpine.start();

CodePen

The issue I'm facing is with sorting. The code in the onEnd callback updates the rules object so that the JSON output shows the rules in the correct order, however the UI does not update — after dragging a rule to a new position the UI reverts back to how it was originally. If I comment out the stuff in the onEnd callback the UI sorting works correctly but the JSON object doesn't change hence is now in the wrong order.

I'd like the two things to be in sync so that the UI updates correctly when sorted and the JSON is also updated to reflect the new order.

Why this is happening and how can I fix it?


Solution

  • After a long struggle of trial, error and further searching I came to the conclusion that modifying the rules after sorting was causing the DOM issues. All the data was correct, but the DOM wouldn't properly show it. I came to this issue which suggested using Alpine.raw:

     initSortable() {
          const rulesContainer = document.querySelector('.configurator') as HTMLElement;
          Sortable.create(rulesContainer, {
            handle: '.rule__sort',
            onEnd: (event: SortableEvent) => {
              console.log(event);
              let rules = Alpine.raw(this.rules);
              const movedRule = rules.splice(event.oldIndex!, 1)[0];
              rules.splice(event.newIndex!, 0, movedRule);
              this.rules = rules;
              this.updateOutput();
            }
          });
        },
    

    This is not in the docs, but it basically seems to remove the proxy wrapper from the data, which allows you to modify it without modifying the proxy instance which seems to be what's causing the issue.

    If you were to not unwrap (let rules = this.rules), and you modify rules, it would also still modify this.rules.

    Note: I did get some other reordering issues:

    • I was able to move the data below the buttons
    • Adding new rows after reorder jumped the order, potentially due to the key being index based (perhaps add a random ID based on datetime)