Browse Source

add implementation and tests

Caleb Porzio 1 year ago
parent
commit
6750dcad51

+ 32 - 14
package-lock.json

@@ -8,9 +8,10 @@
                 "packages/*"
             ],
             "dependencies": {
-                "@floating-ui/dom": "^1.5.3"
+                "sortablejs": "^1.15.2"
             },
             "devDependencies": {
+                "@floating-ui/dom": "^1.5.3",
                 "axios": "^0.21.1",
                 "chalk": "^4.1.1",
                 "cypress": "^7.0.0",
@@ -64,6 +65,10 @@
             "resolved": "packages/persist",
             "link": true
         },
+        "node_modules/@alpinejs/sort": {
+            "resolved": "packages/sort",
+            "link": true
+        },
         "node_modules/@alpinejs/ui": {
             "resolved": "packages/ui",
             "link": true
@@ -1025,6 +1030,7 @@
             "version": "1.5.0",
             "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz",
             "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==",
+            "dev": true,
             "dependencies": {
                 "@floating-ui/utils": "^0.1.3"
             }
@@ -1033,6 +1039,7 @@
             "version": "1.5.3",
             "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz",
             "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==",
+            "dev": true,
             "dependencies": {
                 "@floating-ui/core": "^1.4.2",
                 "@floating-ui/utils": "^0.1.3"
@@ -1041,7 +1048,8 @@
         "node_modules/@floating-ui/utils": {
             "version": "0.1.6",
             "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
-            "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A=="
+            "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==",
+            "dev": true
         },
         "node_modules/@istanbuljs/load-nyc-config": {
             "version": "1.1.0",
@@ -6833,6 +6841,11 @@
                 "node": ">=0.10.0"
             }
         },
+        "node_modules/sortablejs": {
+            "version": "1.15.2",
+            "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
+            "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
+        },
         "node_modules/source-map": {
             "version": "0.6.1",
             "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -7854,37 +7867,38 @@
             }
         },
         "packages/alpinejs": {
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT",
             "dependencies": {
                 "@vue/reactivity": "~3.1.1"
             }
         },
         "packages/anchor": {
-            "version": "3.13.2",
+            "name": "@alpinejs/anchor",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/collapse": {
             "name": "@alpinejs/collapse",
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/csp": {
             "name": "@alpinejs/csp",
-            "version": "3.0.0-alpha.0",
+            "version": "3.13.8",
             "license": "MIT",
             "dependencies": {
-                "@vue/reactivity": "^3.0.2"
+                "@vue/reactivity": "~3.1.1"
             }
         },
         "packages/docs": {
             "name": "@alpinejs/docs",
-            "version": "3.13.2-revision.1",
+            "version": "3.13.8-revision.1",
             "license": "MIT"
         },
         "packages/focus": {
             "name": "@alpinejs/focus",
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT",
             "dependencies": {
                 "focus-trap": "^6.9.4",
@@ -7901,17 +7915,17 @@
         },
         "packages/intersect": {
             "name": "@alpinejs/intersect",
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/mask": {
             "name": "@alpinejs/mask",
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/morph": {
             "name": "@alpinejs/morph",
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/navigate": {
@@ -7924,12 +7938,16 @@
         },
         "packages/persist": {
             "name": "@alpinejs/persist",
-            "version": "3.13.2",
+            "version": "3.13.8",
+            "license": "MIT"
+        },
+        "packages/sort": {
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/ui": {
             "name": "@alpinejs/ui",
-            "version": "3.13.1-beta.0",
+            "version": "3.13.8-beta.0",
             "license": "MIT",
             "devDependencies": {}
         }

+ 4 - 1
package.json

@@ -4,11 +4,11 @@
         "packages/*"
     ],
     "devDependencies": {
+        "@floating-ui/dom": "^1.5.3",
         "axios": "^0.21.1",
         "chalk": "^4.1.1",
         "cypress": "^7.0.0",
         "cypress-plugin-tab": "^1.0.5",
-        "@floating-ui/dom": "^1.5.3",
         "dot-json": "^1.2.2",
         "esbuild": "~0.16.17",
         "jest": "^26.6.3"
@@ -21,5 +21,8 @@
         "jest": "jest test",
         "update-docs": "node ./scripts/update-docs.js",
         "release": "node ./scripts/release.js"
+    },
+    "dependencies": {
+        "sortablejs": "^1.15.2"
     }
 }

+ 5 - 0
packages/sort/builds/cdn.js

@@ -0,0 +1,5 @@
+import sort from '../src/index.js'
+
+document.addEventListener('alpine:init', () => {
+    window.Alpine.plugin(sort)
+})

+ 5 - 0
packages/sort/builds/module.js

@@ -0,0 +1,5 @@
+import sort  from '../src/index.js'
+
+export default sort
+
+export { sort }

+ 15 - 0
packages/sort/package.json

@@ -0,0 +1,15 @@
+{
+    "name": "@alpinejs/sort",
+    "version": "3.13.8",
+    "description": "An Alpine plugin for drag sorting items on the page",
+    "homepage": "https://alpinejs.dev/plugins/mask",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/sort"
+    },
+    "author": "Caleb Porzio",
+    "license": "MIT",
+    "main": "dist/module.cjs.js",
+    "module": "dist/module.esm.js"
+}

+ 127 - 0
packages/sort/src/index.js

@@ -0,0 +1,127 @@
+import Sortable from 'sortablejs'
+
+export default function (Alpine) {
+    Alpine.directive('sort', (el, { value, modifiers, expression }, { effect, evaluate, evaluateLater, cleanup }) => {
+        if (value === 'config') {
+            return // This will get handled by the main directive...
+        }
+
+        if (value === 'handle') {
+            return // This will get handled by the main directive...
+        }
+
+        if (value === 'item') {
+            if ([undefined, null, ''].includes(expression)) return
+
+            el._x_sort_key = evaluate(expression)
+
+            return
+        }
+
+        let preferences = {
+            hideGhost: ! modifiers.includes('ghost'),
+            useHandles: !! el.querySelector('[x-sort\\:handle]'),
+            group: modifiers.indexOf('group') !== -1 ? modifiers[modifiers.indexOf('group') + 1] : null,
+        }
+
+        let handleSort = generateSortHandler(expression, evaluateLater)
+
+        let config = getConfigurationOverrides(el, modifiers, evaluate)
+
+        let sortable = initSortable(el, config, preferences, (key, position) => {
+            handleSort(key, position)
+        })
+
+        cleanup(() => sortable.destroy())
+    })
+}
+
+function generateSortHandler(expression, evaluateLater) {
+    // No handler was passed to x-sort...
+    if ([undefined, null, ''].includes(expression)) return () => {}
+
+    let handle = evaluateLater(expression)
+
+    return (key, position) => {
+        // In the case of `x-sort="handleSort"`, let us call it manually...
+        Alpine.dontAutoEvaluateFunctions(() => {
+            handle(
+                // If a function is returned, call it with the key/position params...
+                received => {
+                    if (typeof received === 'function') received(key, position)
+                },
+                // Provide $key and $position to the scope in case they want to call their own function...
+                { scope: {
+                    $key: key,
+                    $position: position,
+                } },
+            )
+        })
+    }
+}
+
+function getConfigurationOverrides(el, modifiers, evaluate)
+{
+    return el.hasAttribute('x-sort:config')
+        ? evaluate(el.getAttribute('x-sort:config'))
+        : {}
+}
+
+function initSortable(el, config, preferences, handle) {
+    let ghostRef
+
+    let options = {
+        animation: 150,
+
+        handle: preferences.useHandles ? '[x-sort\\:handle]' : null,
+
+        group: preferences.group,
+
+        onSort(e) {
+            // If item has been dragged between groups...
+            if (e.from !== e.to) {
+                // And this is the group it was dragged FROM...
+                if (e.to !== e.target) {
+                    return // Don't do anything, because the other group will call the handler...
+                }
+            }
+
+            let key = e.item._x_sort_key
+            let position = e.newIndex
+
+            if (key !== undefined || key !== null) {
+                handle(key, position)
+            }
+        },
+
+        onStart() {
+            ghostRef = document.querySelector('.sortable-ghost')
+
+            if (preferences.hideGhost && ghostRef) ghostRef.style.opacity = '0'
+        },
+
+
+        onEnd() {
+            if (preferences.hideGhost && ghostRef) ghostRef.style.opacity = '1'
+
+            ghostRef = undefined
+
+            keepElementsWithinMorphMarkers(el)
+        }
+    }
+
+    return new Sortable(el, { ...options, ...config })
+}
+
+function keepElementsWithinMorphMarkers(el) {
+    let cursor = el.firstChild
+
+    while (cursor.nextSibling) {
+        if (cursor.textContent.trim() === '[if ENDBLOCK]><![endif]') {
+            el.append(cursor)
+            break
+        }
+
+        cursor = cursor.nextSibling
+    }
+}

+ 1 - 0
scripts/build.js

@@ -14,6 +14,7 @@ let zlib = require('zlib');
     'anchor',
     'morph',
     'focus',
+    'sort',
     'mask',
     'ui',
 ]).forEach(package => {

+ 174 - 0
tests/cypress/integration/plugins/sort.spec.js

@@ -0,0 +1,174 @@
+import { haveText, html, test } from '../../utils'
+
+test('basic drag sorting works',
+    [html`
+        <div x-data>
+            <ul x-sort>
+                <li x-sort:item id="1">foo</li>
+                <li x-sort:item id="2">bar</li>
+                <li x-sort:item id="3">baz</li>
+            </ul>
+        </div>
+    `],
+    async ({ get }) => {
+        get('ul li').eq(0).should(haveText('foo'))
+        get('ul li').eq(1).should(haveText('bar'))
+        get('ul li').eq(2).should(haveText('baz'))
+
+        await get('#1').drag('#3')
+
+        get('ul li').eq(0).should(haveText('bar'))
+        get('ul li').eq(1).should(haveText('baz'))
+        get('ul li').eq(2).should(haveText('foo'))
+
+        await get('#3').drag('#1')
+
+        get('ul li').eq(0).should(haveText('bar'))
+        get('ul li').eq(1).should(haveText('foo'))
+        get('ul li').eq(2).should(haveText('baz'))
+    },
+)
+
+test('can use a custom handle',
+    [html`
+        <div x-data>
+            <ul x-sort>
+                <li x-sort:item id="1"><span x-sort:handle>handle</span> - foo</li>
+                <li x-sort:item id="2"><span x-sort:handle>handle</span> - bar</li>
+            </ul>
+        </div>
+    `],
+    async ({ get }) => {
+        get('ul li').eq(0).should(haveText('handle - foo'))
+        get('ul li').eq(1).should(haveText('handle - bar'))
+
+        await get('#1 span').drag('#2')
+
+        get('ul li').eq(0).should(haveText('handle - bar'))
+        get('ul li').eq(1).should(haveText('handle - foo'))
+    },
+)
+
+test('can move items between groups',
+    [html`
+        <div x-data>
+            <ul x-sort.group.one>
+                <li x-sort:item id="1">foo</li>
+                <li x-sort:item id="2">bar</li>
+            </ul>
+
+            <ol x-sort.group.one>
+                <li x-sort:item id="3">oof</li>
+                <li x-sort:item id="4">rab</li>
+            </ol>
+        </div>
+    `],
+    async ({ get }) => {
+        get('ul li').eq(0).should(haveText('foo'))
+        get('ul li').eq(1).should(haveText('bar'))
+        get('ol li').eq(0).should(haveText('oof'))
+        get('ol li').eq(1).should(haveText('rab'))
+
+        await get('#1').drag('#4')
+
+        get('ul li').eq(0).should(haveText('bar'))
+        get('ol li').eq(0).should(haveText('oof'))
+        get('ol li').eq(1).should(haveText('foo'))
+        get('ol li').eq(2).should(haveText('rab'))
+    },
+)
+
+test('sort handle method',
+    [html`
+        <div x-data="{ handle(key, position) { $refs.outlet.textContent = key+'-'+position } }">
+            <ul x-sort="handle">
+                <li x-sort:item="1" id="1">foo</li>
+                <li x-sort:item="2" id="2">bar</li>
+                <li x-sort:item="3" id="3">baz</li>
+            </ul>
+
+            <h1 x-ref="outlet"></h1>
+        </div>
+    `],
+    async ({ get }) => {
+        await get('#1').drag('#3')
+
+        get('h1').should(haveText('1-2'))
+
+        await get('#3').drag('#1')
+
+        get('h1').should(haveText('3-2'))
+    },
+)
+
+test('can access key and position in handler',
+    [html`
+        <div x-data="{ handle(key, position) { $refs.outlet.textContent = key+'-'+position } }">
+            <ul x-sort="handle($position, $key)">
+                <li x-sort:item="1" id="1">foo</li>
+                <li x-sort:item="2" id="2">bar</li>
+                <li x-sort:item="3" id="3">baz</li>
+            </ul>
+
+            <h1 x-ref="outlet"></h1>
+        </div>
+    `],
+    async ({ get }) => {
+        await get('#1').drag('#3')
+
+        get('h1').should(haveText('2-1'))
+
+        await get('#3').drag('#1')
+
+        get('h1').should(haveText('2-3'))
+    },
+)
+
+test('can use custom sortablejs configuration',
+    [html`
+        <div x-data>
+            <ul x-sort x-sort:config="{ filter: '[data-ignore]' }">
+                <li x-sort:item id="1" data-ignore>foo</li>
+                <li x-sort:item id="2">bar</li>
+                <li x-sort:item id="3">baz</li>
+            </ul>
+        </div>
+    `],
+    async ({ get }) => {
+        get('ul li').eq(0).should(haveText('foo'))
+        get('ul li').eq(1).should(haveText('bar'))
+        get('ul li').eq(2).should(haveText('baz'))
+
+        await get('#1').drag('#3')
+
+        get('ul li').eq(0).should(haveText('foo'))
+        get('ul li').eq(1).should(haveText('bar'))
+        get('ul li').eq(2).should(haveText('baz'))
+
+        await get('#3').drag('#1')
+
+        get('ul li').eq(0).should(haveText('baz'))
+        get('ul li').eq(1).should(haveText('foo'))
+        get('ul li').eq(2).should(haveText('bar'))
+    },
+)
+
+test('works with Livewire morphing',
+    [html`
+        <div x-data>
+            <ul x-sort>
+                <!-- [if BLOCK]><![endif] -->
+                <li x-sort:item id="1">foo</li>
+                <li x-sort:item id="2">bar</li>
+                <li x-sort:item id="3">baz</li>
+                <!-- [if ENDBLOCK]><![endif] -->
+            </ul>
+        </div>
+    `],
+    async ({ get }) => {
+        await get('#1').drag('#3')
+
+        // This is the easiest way I can think of to assert the order of HTML comments doesn't change...
+        get('ul').should('have.html', `\n                <!-- [if BLOCK]><![endif] -->\n                \n                <li x-sort:item="" id="2" style="">bar</li>\n                <li x-sort:item="" id="3" style="">baz</li>\n                \n            <li x-sort:item="" id="1" draggable="false" class="" style="opacity: 1;">foo</li><!-- [if ENDBLOCK]><![endif] -->`)
+    },
+)

+ 1 - 0
tests/cypress/spec.html

@@ -13,6 +13,7 @@
     <script src="/../../packages/collapse/dist/cdn.js"></script>
     <script src="/../../packages/anchor/dist/cdn.js"></script>
     <script src="/../../packages/mask/dist/cdn.js"></script>
+    <script src="/../../packages/sort/dist/cdn.js"></script>
     <script src="/../../packages/ui/dist/cdn.js"></script>
     <script>
         let root = document.querySelector('#root')

+ 154 - 0
tests/cypress/support/drag.js

@@ -0,0 +1,154 @@
+const dataTransfer = new DataTransfer()
+
+function omit(object = {}, keys = []) {
+    return Object.entries(object).reduce((accum, [key, value]) => (key in keys ? accum : { ...accum, [key]: value }), {})
+}
+
+function isAttached(element) {
+    return !!element.closest('html')
+}
+
+const DragSimulator = {
+    MAX_TRIES: 5,
+    DELAY_INTERVAL_MS: 10,
+    counter: 0,
+    targetElement: null,
+    rectsEqual(r1, r2) {
+        return r1.top === r2.top && r1.right === r2.right && r1.bottom === r2.bottom && r1.left === r2.left
+    },
+    createDefaultOptions(options) {
+        const commonOptions = omit(options, ['source', 'target'])
+        const source = { ...commonOptions, ...options.source }
+        const target = { ...commonOptions, ...options.target }
+        return { source, target }
+    },
+    get dropped() {
+        const currentSourcePosition = this.source.getBoundingClientRect()
+        return !this.rectsEqual(this.initialSourcePosition, currentSourcePosition)
+    },
+    get hasTriesLeft() {
+        return this.counter < this.MAX_TRIES
+    },
+    set target(target) {
+        this.targetElement = target
+    },
+    get target() {
+        return cy.wrap(this.targetElement)
+    },
+    dragstart(clientPosition = {}) {
+        return cy
+            .wrap(this.source)
+            .trigger('pointerdown', {
+                which: 1,
+                button: 0,
+                ...clientPosition,
+                eventConstructor: 'PointerEvent',
+                ...this.options.source,
+            })
+            .trigger('mousedown', {
+                which: 1,
+                button: 0,
+                ...clientPosition,
+                eventConstructor: 'MouseEvent',
+                ...this.options.source,
+            })
+            .trigger('dragstart', { dataTransfer, eventConstructor: 'DragEvent', ...this.options.source })
+    },
+    drop(clientPosition = {}) {
+        return this.target
+            .trigger('drop', {
+                dataTransfer,
+                eventConstructor: 'DragEvent',
+                ...this.options.target,
+            })
+            .then(() => {
+                if (isAttached(this.targetElement)) {
+                    this.target
+                        .trigger('mouseup', {
+                            which: 1,
+                            button: 0,
+                            ...clientPosition,
+                            eventConstructor: 'MouseEvent',
+                            ...this.options.target,
+                        })
+                        .then(() => {
+                            if (isAttached(this.targetElement)) {
+                                this.target.trigger('pointerup', {
+                                    which: 1,
+                                    button: 0,
+                                    ...clientPosition,
+                                    eventConstructor: 'PointerEvent',
+                                    ...this.options.target,
+                                })
+                            }
+                        })
+                }
+            })
+    },
+    dragover(clientPosition = {}) {
+        if (!this.counter || (!this.dropped && this.hasTriesLeft)) {
+            this.counter += 1
+            return this.target
+                .trigger('dragover', {
+                    dataTransfer,
+                    eventConstructor: 'DragEvent',
+                    ...this.options.target,
+                })
+                .trigger('mousemove', {
+                    ...this.options.target,
+                    ...clientPosition,
+                    eventConstructor: 'MouseEvent',
+                })
+                .trigger('pointermove', {
+                    ...this.options.target,
+                    ...clientPosition,
+                    eventConstructor: 'PointerEvent',
+                })
+                .wait(this.DELAY_INTERVAL_MS)
+                .then(() => this.dragover(clientPosition))
+        }
+        if (!this.dropped) {
+            console.error(`Exceeded maximum tries of: ${this.MAX_TRIES}, aborting`)
+            return false
+        } else {
+            return true
+        }
+    },
+    init(source, target, options = {}) {
+        this.options = this.createDefaultOptions(options)
+        this.counter = 0
+        this.source = source.get(0)
+        this.initialSourcePosition = this.source.getBoundingClientRect()
+        return cy.get(target).then((targetWrapper) => {
+            this.target = targetWrapper.get(0)
+        })
+    },
+    drag(sourceWrapper, targetSelector, options) {
+        this.init(sourceWrapper, targetSelector, options)
+            .then(() => this.dragstart())
+            .then(() => this.dragover())
+            .then((success) => {
+                if (success) {
+                    return this.drop().then(() => true)
+                } else {
+                    return false
+                }
+            })
+    },
+    move(sourceWrapper, options) {
+        const { deltaX, deltaY } = options
+        const { top, left } = sourceWrapper.offset()
+        const finalCoords = { clientX: left + deltaX, clientY: top + deltaY }
+        this.init(sourceWrapper, sourceWrapper, options)
+            .then(() => this.dragstart({ clientX: left, clientY: top }))
+            .then(() => this.dragover(finalCoords))
+            .then(() => this.drop(finalCoords))
+    },
+}
+
+function addChildCommand(name, command) {
+    Cypress.Commands.add(name, { prevSubject: 'element' }, (...args) => command(...args))
+}
+
+addChildCommand('drag', DragSimulator.drag.bind(DragSimulator))
+addChildCommand('move', DragSimulator.move.bind(DragSimulator))

+ 3 - 0
tests/cypress/support/index.js

@@ -16,6 +16,9 @@
 // Import commands.js using ES2015 syntax:
 import './commands'
 
+// Import drag and drop helpers (https://github.com/4teamwork/cypress-drag-drop/blob/master/index.js)
+import './drag'
+
 // Alternatively you can use CommonJS syntax:
 // require('./commands')
 require('cypress-plugin-tab')