Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5.x] Ability to drag/drop sets between replicators #10361

Open
wants to merge 18 commits into
base: 5.x
Choose a base branch
from
19 changes: 19 additions & 0 deletions resources/css/components/fieldtypes/replicator.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

.replicator-set-container {
@apply outline-none;
min-height: 1rem;
}

.replicator-set {
Expand Down Expand Up @@ -80,3 +81,21 @@
.replicator-fullscreen {
@apply fixed bg-gray-200 inset-0 min-h-screen overflow-scroll rounded-none;
}

@scope (.replicator-droppable) to (.replicator-fieldtype) {
:scope {
cursor: auto;
}
* {
pointer-events: auto;
}
}

@scope (.replicator-not-droppable) to (.replicator-fieldtype) {
:scope {
cursor: not-allowed;
}
* {
pointer-events: none;
}
}
2 changes: 2 additions & 0 deletions resources/js/bootstrap/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import ResourceDeleter from '../components/ResourceDeleter.vue';
import Stack from '../components/stacks/Stack.vue';
import StackTest from '../components/stacks/StackTest.vue';
import CodeBlock from '../components/CodeBlock.vue';
import SortableList from '../components/sortable/SortableList.vue';

// Third Party
Vue.component('v-select', vSelect)
Expand Down Expand Up @@ -129,6 +130,7 @@ Vue.component('create-entry-button', CreateEntryButton);
Vue.component('popover', Popover);
Vue.component('portal', Portal);
Vue.component('code-block', CodeBlock);
Vue.component('sortable-list', SortableList);

// Recursive
Vue.component('role-permission-tree', PermissionTree);
Expand Down
36 changes: 30 additions & 6 deletions resources/js/bootstrap/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,18 @@ export function replicatorPreviewHtml(html) {
return new PreviewHtml(html);
}

export function closestVm(el, name) {
let parent = el;
while (parent) {
if (parent.__vue__) break;
parent = parent.parentElement;
export function closestVm(obj, name) {
let vm, parent;
if (obj instanceof HTMLElement) {
parent = obj;
while (parent) {
if (parent.__vue__) break;
parent = parent.parentElement;
}
vm = parent.__vue__;
} else {
vm = obj;
}
let vm = parent.__vue__;
while (vm !== vm.$root) {
if (!name || name === vm.$options.name) return vm;
vm = vm.$parent;
Expand All @@ -138,3 +143,22 @@ export function str_slug(string) {
export function snake_case(string) {
return Statamic.$slug.separatedBy('_').create(string);
}

export function arrayAdd(items, item, index) {
return [
...items.slice(0, index),
item,
...items.slice(index, items.length)
]
}

export function arrayRemove(items, index) {
return [
...items.slice(0, index),
...items.slice(index + 1, items.length)
]
}

export function arrayMove(items, oldIndex, newIndex) {
return arrayAdd(arrayRemove(items, oldIndex), items[oldIndex], newIndex);
}
79 changes: 64 additions & 15 deletions resources/js/components/fieldtypes/replicator/Replicator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
once it has been portaled out. -->
<div :class="{ 'publish-fields': fullScreenMode }">
<div :class="{ wrapperClasses: fullScreenMode }">
<div class="replicator-fieldtype-container" :class="{'replicator-fullscreen bg-gray-200 dark:bg-dark-700': fullScreenMode }">
<div class="replicator-fieldtype-container" :class="{
'replicator-fullscreen bg-gray-200 dark:bg-dark-700': fullScreenMode,
'replicator-droppable': canDropSet === true,
'replicator-not-droppable': canDropSet === false,
}">

<header class="bg-white dark:bg-dark-550 fixed top-0 inset-x-0 border-b dark:border-dark-900 p-3 rtl:pr-4 ltr:pl-4 flex items-center justify-between shadow z-max" v-if="fullScreenMode">
<h2 v-text="__(config.display)" class="flex-1" />
Expand Down Expand Up @@ -43,11 +47,15 @@
<sortable-list
:value="value"
:vertical="true"
:item-class="sortableItemClass"
:handle-class="sortableHandleClass"
group="replicator-fieldtype"
:group-validator="sortableGroupValidator"
item-class="replicator-sortable-item"
handle-class="replicator-sortable-handle"
append-to="body"
constrain-dimensions
@input="sorted($event)"
@groupstart="sortableGroupStart"
@groupend="sortableGroupEnd"
@dragstart="$emit('focus')"
@dragend="$emit('blur')"
>
Expand All @@ -59,9 +67,10 @@
:values="set"
:meta="meta.existing[set._id]"
:config="setConfig(set.type)"
:config-hash="setConfigHash(set.type)"
:parent-name="name"
:sortable-item-class="sortableItemClass"
:sortable-handle-class="sortableHandleClass"
sortable-item-class="replicator-sortable-item"
sortable-handle-class="replicator-sortable-handle"
:is-read-only="isReadOnly"
:collapsed="collapsed.includes(set._id)"
:field-path-prefix="fieldPathPrefix || handle"
Expand Down Expand Up @@ -117,6 +126,7 @@ import AddSetButton from './AddSetButton.vue';
import ManagesSetMeta from './ManagesSetMeta';
import { SortableList } from '../../sortable/Sortable';
import reduce from 'underscore/modules/reduce';
import { closestVm } from '../../../bootstrap/globals';

export default {

Expand All @@ -136,6 +146,7 @@ export default {
collapsed: clone(this.meta.collapsed),
previews: this.meta.previews,
fullScreenMode: false,
canDropSet: null,
provide: {
storeName: this.storeName,
replicatorSets: this.config.sets
Expand All @@ -157,16 +168,12 @@ export default {
}, []);
},

groupConfigs() {
return this.config.sets;
setConfigHashes() {
return this.meta.setConfigHashes;
},

sortableItemClass() {
return `${this.name}-sortable-item`;
},

sortableHandleClass() {
return `${this.name}-sortable-handle`;
groupConfigs() {
return this.config.sets;
},

storeState() {
Expand All @@ -186,6 +193,10 @@ export default {
return _.find(this.setConfigs, { handle }) || {};
},

setConfigHash(handle) {
return this.setConfigHashes[handle];
},

updated(index, set) {
this.update([...this.value.slice(0, index), set, ...this.value.slice(index + 1)]);
},
Expand All @@ -196,8 +207,32 @@ export default {
this.update([...this.value.slice(0, index), ...this.value.slice(index + 1)]);
},

sorted(value) {
this.update(value);
sorted({ operation, oldIndex, newIndex, oldList }) {
if (operation === 'move') {
// Move set within this replicator
this.update(arrayMove(this.value, oldIndex, newIndex));
} else if (operation === 'add') {
// Add set to this replicator
const oldReplicator = closestVm(oldList, 'replicator-fieldtype');
const set = oldReplicator.value[oldIndex];
const meta = oldReplicator.meta.existing[set._id];
const previews = oldReplicator.previews[set._id];
this.updateSetPreviews(set._id, previews);
this.updateSetMeta(set._id, meta);
this.update(arrayAdd(this.value, set, newIndex));
if (oldReplicator.collapsed.includes(set._id)) {
this.collapseSet(set._id);
} else {
this.expandSet(set._id);
}
// Remove set from old replicator
// Do this from the target replicator in order to avoid race conditions with nested
// replicators both trying to update their parent's value at the same time.
this.$nextTick(() => {
oldReplicator.removeSetMeta(set._id);
oldReplicator.update(arrayRemove(oldReplicator.value, oldIndex));
});
}
},

addSet(handle, index) {
Expand Down Expand Up @@ -285,6 +320,20 @@ export default {

return Object.keys(this.storeState.errors ?? []).some(handle => handle.startsWith(prefix));
},

sortableGroupValidator({ source }) {
this.canDropSet = this.canAddSet && Object.values(this.setConfigHashes).includes(source.dataset.configHash);
return this.canDropSet;
},

sortableGroupStart({ valid }) {
this.canDropSet = valid;
},

sortableGroupEnd() {
this.canDropSet = null;
},

},

mounted() {
Expand Down
6 changes: 5 additions & 1 deletion resources/js/components/fieldtypes/replicator/Set.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>

<div :class="sortableItemClass">
<div :class="sortableItemClass" :data-config-hash="configHash">
<slot name="picker" />
<div class="replicator-set" :class="{ 'has-error': this.hasError }">

Expand Down Expand Up @@ -91,6 +91,10 @@ export default {
type: Object,
required: true
},
configHash: {
type: String,
required: true
},
meta: {
type: Object,
required: true
Expand Down
88 changes: 70 additions & 18 deletions resources/js/components/sortable/SortableList.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
<script>
import { Sortable, Plugins } from '@shopify/draggable'
import { closestVm } from '../../bootstrap/globals';

function move(items, oldIndex, newIndex) {
const itemRemovedArray = [
...items.slice(0, oldIndex),
...items.slice(oldIndex + 1, items.length)
]

return [
...itemRemovedArray.slice(0, newIndex),
items[oldIndex],
...itemRemovedArray.slice(newIndex, itemRemovedArray.length)
]
}
const instances = {};

export default {

props: {
value: {
required: true,
},
group: {
default: null,
},
groupValidator: {
default: null,
},
itemClass: {
default: 'sortable-item',
},
Expand Down Expand Up @@ -63,6 +59,7 @@ export default {
data() {
return {
sortable: null,
instanceId: this.group || uniqid(),
}
},

Expand Down Expand Up @@ -119,18 +116,53 @@ export default {
},

methods: {

setupSortableList() {
this.sortable = new Sortable(this.$el, this.computedOptions);
this.sortable = this.connectInstace(this.instanceId, this.$el, this.computedOptions);

this.sortable.on('drag:start', () => this.$emit('dragstart'));
this.sortable.on('drag:stop', () => this.$emit('dragend'));

this.sortable.on('sortable:stop', ({ oldIndex, newIndex }) => {
this.$emit('input', move(this.value, oldIndex, newIndex))
})
this.sortable.on('sortable:stop', (event) => {
const { oldIndex, newIndex, oldContainer, newContainer } = event;
if (!this.group) {
this.$emit('input', arrayMove(this.value, oldIndex, newIndex));
return;
}
const payload = { oldIndex, newIndex };
if (newContainer === this.$el && oldContainer === this.$el) {
this.$emit('input', { operation: 'move', oldList: this, newList: this, ...payload });
} else if (newContainer === this.$el) {
this.$emit('input', { operation: 'add', oldList: closestVm(oldContainer, 'sortable-list'), newList: this, ...payload });
} else if (oldContainer === this.$el) {
this.$emit('input', { operation: 'remove', oldList: this, newList: closestVm(newContainer, 'sortable-list'), ...payload });
}
});

if (this.group && this.groupValidator) {
this.sortable.on('sortable:sort', (event) => {
const { dragEvent } = event;
const { sourceContainer, overContainer, source } = dragEvent;
if (overContainer !== this.$el || sourceContainer === this.$el) {
return;
}
if (!this.groupValidator({ source })) {
event.cancel();
}
});
this.sortable.on('sortable:start', (event) => {
const { dragEvent } = event;
const { source } = dragEvent;
const valid = this.groupValidator({ source });
this.$emit('groupstart', { valid });
});
this.sortable.on('sortable:stop', () => {
this.$emit('groupend');
});
}

this.$on('hook:destroyed', () => {
this.sortable.destroy()
this.destroySortableList();
})

if (this.mirror === false) {
Expand All @@ -139,8 +171,28 @@ export default {
},

destroySortableList() {
this.sortable.destroy()
this.disconnectInstace(this.instanceId, this.$el);
},

connectInstace(id, container, options) {
if (!instances[id]) {
instances[id] = new Sortable(container, options);
} else {
instances[id].addContainer(container);
}
return instances[id];
},

disconnectInstace(id, container) {
if (instances[id]) {
instances[id].removeContainer(container);
if (instances[id].containers.length === 0) {
instances[id].destroy();
delete instances[id];
}
}
},

},

watch: {
Expand Down
Loading
Loading