Browse Source

Add x-for directive with tests

Caleb Porzio 5 years ago
parent
commit
92860710a0
3 changed files with 301 additions and 23 deletions
  1. 101 21
      src/component.js
  2. 24 2
      src/utils.js
  3. 176 0
      test/for.spec.js

+ 101 - 21
src/component.js

@@ -1,4 +1,4 @@
-import { arrayUnique, walk, keyToModifier, saferEval, saferEvalNoReturn, getXAttrs, debounce, transitionIn, transitionOut } from './utils'
+import { arrayUnique, walk, keyToModifier, saferEval, saferEvalNoReturn, getXAttrs, debounce, transitionIn, transitionOut, parseFor } from './utils'
 
 export default class Component {
     constructor(el) {
@@ -119,42 +119,53 @@ export default class Component {
                 }
             }
 
-            callback(el)
+            return callback(el)
         })
     }
 
-    initializeElements(rootEl) {
+    initializeElements(rootEl, extraVars = {}, skipForLoopSpawns = true) {
         this.walkAndSkipNestedComponents(rootEl, el => {
-            this.initializeElement(el)
+            // Don't touch spawns from for loop
+            if (skipForLoopSpawns && el.__x_for_key !== undefined) return false
+
+            this.initializeElement(el, extraVars)
         }, el => {
             el.__x = new Component(el)
         })
+
+        // Walk through the $nextTick stack and clear it as we go.
+        while (this.nextTickStack.length > 0) {
+            this.nextTickStack.shift()()
+        }
     }
 
-    initializeElement(el) {
+    initializeElement(el, extraVars = {}) {
         // To support class attribute merging, we have to know what the element's
         // original class attribute looked like for reference.
         if (el.hasAttribute('class') && getXAttrs(el).length > 0) {
             el.__originalClasses = el.getAttribute('class').split(' ')
         }
 
-        this.registerListeners(el)
-        this.resolveBoundAttributes(el, true)
+        this.registerListeners(el, extraVars)
+        this.resolveBoundAttributes(el, true, extraVars)
     }
 
-    updateElements(rootEl) {
+    updateElements(rootEl, extraVars = {}, skipForLoopSpawns = true) {
         this.walkAndSkipNestedComponents(rootEl, el => {
-            this.updateElement(el)
+            // Don't touch spawns from for loop (and check if the root is actually a for loop in a parent, don't skip it.)
+            if (skipForLoopSpawns && el.__x_for_key !== undefined && ! el.isSameNode(this.$el)) return false
+
+            this.updateElement(el, extraVars)
         }, el => {
             el.__x = new Component(el)
         })
     }
 
-    updateElement(el) {
-        this.resolveBoundAttributes(el)
+    updateElement(el, extraVars = {}) {
+        this.resolveBoundAttributes(el, true, extraVars)
     }
 
-    registerListeners(el) {
+    registerListeners(el, extraVars = {}) {
         getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
             switch (type) {
                 case 'on':
@@ -180,41 +191,110 @@ export default class Component {
         })
     }
 
-    resolveBoundAttributes(el, initialUpdate = false) {
+    resolveBoundAttributes(el, initialUpdate = false, extraVars) {
         getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
             switch (type) {
                 case 'model':
                     var attrName = 'value'
-                    var output = this.evaluateReturnExpression(expression)
+                    var output = this.evaluateReturnExpression(expression, extraVars)
                     this.updateAttributeValue(el, attrName, output)
                     break;
 
                 case 'bind':
                     var attrName = value
-                    var output = this.evaluateReturnExpression(expression)
+                    //
+                    if (el.tagName.toLowerCase() === 'template' && attrName === 'key') return
+                    var output = this.evaluateReturnExpression(expression, extraVars)
                     this.updateAttributeValue(el, attrName, output)
                     break;
 
                 case 'text':
-                    var output = this.evaluateReturnExpression(expression)
+                    var output = this.evaluateReturnExpression(expression, extraVars)
                     this.updateTextValue(el, output)
                     break;
 
                 case 'html':
-                    var output = this.evaluateReturnExpression(expression)
+                    var output = this.evaluateReturnExpression(expression, extraVars)
                     this.updateHtmlValue(el, output)
                     break;
 
                 case 'show':
-                    var output = this.evaluateReturnExpression(expression)
+                    var output = this.evaluateReturnExpression(expression, extraVars)
                     this.updateVisibility(el, output, initialUpdate)
                     break;
 
                 case 'if':
-                    var output = this.evaluateReturnExpression(expression)
+                    var output = this.evaluateReturnExpression(expression, extraVars)
                     this.updatePresence(el, output)
                     break;
 
+                case 'for':
+                        const { single, bunch, iterator1, iterator2 } = parseFor(expression)
+
+                        var output = this.evaluateReturnExpression(bunch)
+
+                        var previousEl = el
+                        output.forEach((i, index, group) => {
+                            const nextEl = previousEl.nextElementSibling
+                            let currentEl = nextEl
+                            const keyAttr = getXAttrs(el, 'bind').filter(attr => attr.value === 'key')[0]
+
+                            let keyAliases = { [single]: i }
+                            if (iterator1) keyAliases[iterator1] = index
+                            if (iterator2) keyAliases[iterator2] = group
+
+                            const currentKey = keyAttr
+                                ? this.evaluateReturnExpression(keyAttr.expression, keyAliases)
+                                : index
+
+                            if (nextEl && nextEl.__x_for_key !== undefined) {
+                                // The key is not the same as the item in the dom.
+                                if (nextEl.__x_for_key !== currentKey) {
+                                    // Let's see if it's somewhere else.
+                                    var tmpCurrentEl = currentEl
+                                    while(tmpCurrentEl) {
+                                        if (tmpCurrentEl.__x_for_key === currentKey) {
+                                            el.parentElement.insertBefore(tmpCurrentEl, currentEl)
+                                            currentEl = tmpCurrentEl
+                                            break
+                                        }
+
+                                        tmpCurrentEl = (tmpCurrentEl.nextElementSibling && tmpCurrentEl.nextElementSibling.__x_for_key !== undefined) ? tmpCurrentEl.nextElementSibling : false
+                                    }
+
+                                }
+
+                                this.updateElements(currentEl, {'item': i}, false)
+                            } else {
+                                const clone = document.importNode(el.content, true);
+                                el.parentElement.insertBefore(clone, nextEl)
+
+                                currentEl = previousEl.nextElementSibling
+
+                                transitionIn(currentEl, () => {})
+
+                                this.initializeElements(currentEl, {[single]: i}, false)
+
+                                currentEl.__x_for_key = currentKey
+                            }
+
+                            previousEl = currentEl
+                        })
+
+                        // Clean up oldies
+                        var thing = (previousEl.nextElementSibling && previousEl.nextElementSibling.__x_for_key !== undefined) ? previousEl.nextElementSibling : false
+
+                        while(thing) {
+                            const thingImmutable = thing
+                            transitionOut(thing, () => {
+                                thingImmutable.remove()
+                            })
+
+                            thing = (thing.nextElementSibling && thing.nextElementSibling.__x_for_key !== undefined) ? thing.nextElementSibling : false
+                        }
+                    // this.updatePresence(el, output)
+                    break;
+
                 case 'cloak':
                     el.removeAttribute('x-cloak')
                     break;
@@ -364,8 +444,8 @@ export default class Component {
         })
     }
 
-    evaluateReturnExpression(expression) {
-        return saferEval(expression, this.$data)
+    evaluateReturnExpression(expression, extraData) {
+        return saferEval(expression, this.$data, extraData)
     }
 
     evaluateCommandExpression(expression, extraData) {

+ 24 - 2
src/utils.js

@@ -84,7 +84,7 @@ export function saferEvalNoReturn(expression, dataContext, additionalHelperVaria
 export function isXAttr(attr) {
     const name = replaceAtAndColonWithStandardSyntax(attr.name)
 
-    const xAttrRE = /x-(on|bind|data|text|html|model|if|show|cloak|transition|ref)/
+    const xAttrRE = /x-(on|bind|data|text|html|model|if|for|show|cloak|transition|ref)/
 
     return xAttrRE.test(name)
 }
@@ -95,7 +95,7 @@ export function getXAttrs(el, type) {
         .map(attr => {
             const name = replaceAtAndColonWithStandardSyntax(attr.name)
 
-            const typeMatch = name.match(/x-(on|bind|data|text|html|model|if|show|cloak|transition|ref)/)
+            const typeMatch = name.match(/x-(on|bind|data|text|html|model|if|for|show|cloak|transition|ref)/)
             const valueMatch = name.match(/:([a-zA-Z\-:]+)/)
             const modifiers = name.match(/\.[^.\]]+(?=[^\]]*$)/g) || []
 
@@ -178,3 +178,25 @@ export function transition(el, classesDuring, classesStart, classesEnd, hook1, h
         })
     });
 }
+
+export function parseFor (expression) {
+    const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
+    const stripParensRE = /^\(|\)$/g
+    const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
+    const inMatch = expression.match(forAliasRE)
+    if (! inMatch) return
+    const res = {}
+    res.bunch = inMatch[2].trim()
+    const single = inMatch[1].trim().replace(stripParensRE, '')
+    const iteratorMatch = single.match(forIteratorRE)
+    if (iteratorMatch) {
+      res.single = single.replace(forIteratorRE, '').trim()
+      res.iterator1 = iteratorMatch[1].trim()
+      if (iteratorMatch[2]) {
+        res.iterator2 = iteratorMatch[2].trim()
+      }
+    } else {
+      res.single = single
+    }
+    return res
+  }

+ 176 - 0
test/for.spec.js

@@ -0,0 +1,176 @@
+import Alpine from 'alpinejs'
+import { wait } from '@testing-library/dom'
+
+global.MutationObserver = class {
+    observe() {}
+}
+
+test('x-for', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['foo'] }">
+            <button x-on:click="items = ['foo', 'bar']"></button>
+
+            <template x-for="item in items">
+                <span x-text="item"></span>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span').length).toEqual(1)
+    expect(document.querySelectorAll('span')[0].innerText).toEqual('foo')
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelectorAll('span').length).toEqual(2) })
+
+    expect(document.querySelectorAll('span')[0].innerText).toEqual('foo')
+    expect(document.querySelectorAll('span')[1].innerText).toEqual('bar')
+})
+
+test('removes all elements when array is empty', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['foo'] }">
+            <button x-on:click="items = []"></button>
+
+            <template x-for="item in items">
+                <span x-text="item"></span>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span').length).toEqual(1)
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelectorAll('span').length).toEqual(0) })
+})
+
+test('elements inside of loop are reactive', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['first'], foo: 'bar' }">
+            <button x-on:click="foo = 'baz'"></button>
+
+            <template x-for="item in items">
+                <span>
+                    <h1 x-text="item"></h1>
+                    <h2 x-text="foo"></h2>
+                </span>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span').length).toEqual(1)
+    expect(document.querySelector('h1').innerText).toEqual('first')
+    expect(document.querySelector('h2').innerText).toEqual('bar')
+
+    document.querySelector('button').click()
+
+    await wait(() => {
+        expect(document.querySelector('h1').innerText).toEqual('first')
+        expect(document.querySelector('h2').innerText).toEqual('baz')
+    })
+})
+
+test('components inside of loop are reactive', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['first'] }">
+            <template x-for="item in items">
+                <div x-data="{foo: 'bar'}" class="child">
+                    <span x-text="foo"></span>
+                    <button x-on:click="foo = 'bob'"></button>
+                </div>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('div.child').length).toEqual(1)
+    expect(document.querySelector('span').innerText).toEqual('bar')
+
+    document.querySelector('button').click()
+
+    await wait(() => {
+        expect(document.querySelector('span').innerText).toEqual('bob')
+    })
+})
+
+test('components inside a plain element of loop are reactive', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['first'] }">
+            <template x-for="item in items">
+                <ul>
+                    <div x-data="{foo: 'bar'}" class="child">
+                        <span x-text="foo"></span>
+                        <button x-on:click="foo = 'bob'"></button>
+                    </div>
+                </ul>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('ul').length).toEqual(1)
+    expect(document.querySelector('span').innerText).toEqual('bar')
+
+    document.querySelector('button').click()
+
+    await wait(() => {
+        expect(document.querySelector('span').innerText).toEqual('bob')
+    })
+})
+
+test('adding key attribute moves dom nodes properly', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['foo', 'bar'] }">
+            <button x-on:click="items = ['bar', 'foo', 'baz']"></button>
+
+            <template x-for="item in items" :key="item">
+                <span x-text="item"></span>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span').length).toEqual(2)
+    const itemA = document.querySelectorAll('span')[0]
+    itemA.setAttribute('order', 'first')
+    const itemB = document.querySelectorAll('span')[1]
+    itemB.setAttribute('order', 'second')
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelectorAll('span').length).toEqual(3) })
+
+    expect(document.querySelectorAll('span')[0].getAttribute('order')).toEqual('second')
+    expect(document.querySelectorAll('span')[1].getAttribute('order')).toEqual('first')
+    expect(document.querySelectorAll('span')[2].getAttribute('order')).toEqual(null)
+})
+
+test('can key by index', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['foo', 'bar'] }">
+            <button x-on:click="items = ['bar', 'foo', 'baz']"></button>
+
+            <template x-for="(item, index) in items" :key="index">
+                <span x-text="item"></span>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span').length).toEqual(2)
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelectorAll('span').length).toEqual(3) })
+})