Skip to content

Commit 0a81f17

Browse files
authored
Merge pull request #1 from rtucek/sortable-merge-trap
Move items via drag'n'drop.
2 parents 7132c99 + 162dda6 commit 0a81f17

File tree

11 files changed

+791
-24
lines changed

11 files changed

+791
-24
lines changed

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
"lint": "vue-cli-service lint"
1010
},
1111
"dependencies": {
12+
"@types/sortablejs": "^1.10.2",
1213
"core-js": "^3.3.2",
14+
"sortablejs": "^1.10.2",
1315
"vue": "^2.6.10",
1416
"vue-class-component": "^7.0.2",
15-
"vue-property-decorator": "^8.1.0"
17+
"vue-property-decorator": "^8.1.0",
18+
"vuedraggable": "^2.23.2"
1619
},
1720
"devDependencies": {
1821
"@types/jest": "^24.0.19",

src/MergeTrap.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import Vue from 'vue';
2+
import {
3+
RuleSet, QueryBuilderGroup, ComponentRegistration, MergeTrap as MergeTrapInterface, Rule,
4+
} from '@/types';
5+
6+
function getNextGroup(group: QueryBuilderGroup): QueryBuilderGroup {
7+
if (group.depth < 1) {
8+
return group;
9+
}
10+
11+
let vm: Vue = group;
12+
13+
do {
14+
vm = vm.$parent;
15+
} while (vm.$options.name !== 'QueryBuilderGroup');
16+
17+
return vm as QueryBuilderGroup;
18+
}
19+
20+
function getCommonAncestor(
21+
nodeA: QueryBuilderGroup,
22+
nodeB: QueryBuilderGroup,
23+
): QueryBuilderGroup {
24+
let a = nodeA;
25+
let b = nodeB;
26+
27+
if (a.depth !== b.depth) {
28+
let lower: QueryBuilderGroup = a.depth > b.depth ? a : b;
29+
const higher: QueryBuilderGroup = a.depth < b.depth ? a : b;
30+
31+
while (lower.depth !== higher.depth) {
32+
lower = getNextGroup(lower);
33+
}
34+
35+
// Now both operate on the same level.
36+
a = lower;
37+
b = higher;
38+
}
39+
40+
while (a !== b) {
41+
a = getNextGroup(a);
42+
b = getNextGroup(b);
43+
}
44+
45+
return a;
46+
}
47+
48+
function triggerUpdate(adder: ComponentRegistration, remover: ComponentRegistration): void {
49+
const commonAncestor = getCommonAncestor(adder.component, remover.component);
50+
51+
if (![adder.component, remover.component].includes(commonAncestor)) {
52+
mergeViaParent(commonAncestor, adder, remover);
53+
54+
return;
55+
}
56+
57+
mergeViaNode(commonAncestor, adder, remover);
58+
}
59+
60+
function mergeViaParent(
61+
commonAncestor: QueryBuilderGroup,
62+
adder: ComponentRegistration,
63+
remover: ComponentRegistration,
64+
): void {
65+
let children: Array<RuleSet | Rule> | null = null;
66+
67+
commonAncestor.trap = (position: number, newChild: RuleSet | Rule): void => {
68+
if (children === null) {
69+
children = [...commonAncestor.children];
70+
children.splice(position, 1, newChild);
71+
72+
return;
73+
}
74+
75+
commonAncestor.trap = null;
76+
77+
children.splice(position, 1, newChild);
78+
79+
commonAncestor.$emit(
80+
'query-update',
81+
{
82+
operatorIdentifier: commonAncestor.selectedOperator,
83+
children,
84+
} as RuleSet,
85+
);
86+
};
87+
88+
adder.component.$emit('query-update', adder.ev);
89+
remover.component.$emit('query-update', remover.ev);
90+
}
91+
92+
function mergeViaNode(
93+
parentEmitter: QueryBuilderGroup,
94+
adder: ComponentRegistration,
95+
remover: ComponentRegistration,
96+
): void {
97+
const childEmitter = parentEmitter === adder.component ? remover : adder;
98+
const children = [...parentEmitter.children];
99+
100+
parentEmitter.trap = (position: number, newChild: RuleSet | Rule): void => {
101+
parentEmitter.trap = null; // Release trap
102+
children.splice(position, 1, newChild); // First... accept the update from the child
103+
104+
// Now we'd need to know if the update on the parent is an add or remove action.
105+
if (parentEmitter === adder.component) {
106+
// Parent emitter is adding and child is removing an item.
107+
//
108+
// First, use the event from the child to patch the current state (see above),
109+
// then use the state from the adder for inserting at the right idx.
110+
children.splice(adder.affectedIdx, 0, adder.ev.children[adder.affectedIdx]);
111+
} else {
112+
// Parent emitter is removing and child is adding an item.
113+
//
114+
// Use the event from the child to patch the current state (see above),
115+
// then use the state from the remover to remove the correct item.
116+
children.splice(remover.affectedIdx, 1);
117+
}
118+
119+
parentEmitter.$emit(
120+
'query-update',
121+
{
122+
operatorIdentifier: parentEmitter.selectedOperator,
123+
children,
124+
} as RuleSet,
125+
);
126+
};
127+
128+
childEmitter.component.$emit('query-update', childEmitter.ev);
129+
}
130+
131+
export default class MergeTrap implements MergeTrapInterface {
132+
private eventBus: Vue
133+
134+
constructor() {
135+
this.eventBus = new Vue();
136+
137+
Promise.all<ComponentRegistration>([
138+
new Promise(res => this.eventBus.$once('adderRegistered', res)),
139+
new Promise(res => this.eventBus.$once('removerRegistered', res)),
140+
])
141+
.then((args: ComponentRegistration[]) => triggerUpdate(args[0], args[1]));
142+
}
143+
144+
public registerSortUpdate(update: ComponentRegistration): void {
145+
if (update.adding) {
146+
return this.registerAdder(update);
147+
}
148+
149+
return this.registerRemover(update);
150+
}
151+
152+
protected registerAdder(ev: ComponentRegistration): void {
153+
this.eventBus.$emit('adderRegistered', ev);
154+
}
155+
156+
protected registerRemover(ev: ComponentRegistration): void {
157+
this.eventBus.$emit('removerRegistered', ev);
158+
}
159+
}

src/QueryBuilder.vue

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
<script lang="ts">
2-
import { Component, Vue, Prop } from 'vue-property-decorator';
2+
import {
3+
Component, Vue, Prop, Provide, Watch,
4+
} from 'vue-property-decorator';
35
import { isQueryBuilderConfig, isRuleSet } from '@/guards';
46
import { RuleSet, QueryBuilderConfig } from '@/types';
57
import QueryBuilderGroup from './QueryBuilderGroup.vue';
8+
import MergeTrap from '@/MergeTrap';
69
710
@Component({
811
components: {
912
QueryBuilderGroup,
1013
},
1114
})
1215
export default class QueryBuilder extends Vue {
16+
trap: MergeTrap | null = null
17+
1318
@Prop({
1419
required: true,
1520
validator: query => query === null || isRuleSet(query),
@@ -20,6 +25,15 @@ export default class QueryBuilder extends Vue {
2025
validator: param => isQueryBuilderConfig(param),
2126
}) readonly config!: QueryBuilderConfig
2227
28+
@Provide() getMergeTrap = this.provideMergeTrap
29+
30+
@Watch('value')
31+
removeTrap() {
32+
// If for any reason the parent who actually owns the state updates the query, we'll remove
33+
// cleanup any existing traps.
34+
this.trap = null;
35+
}
36+
2337
get ruleSet(): RuleSet {
2438
if (this.value) {
2539
return this.value;
@@ -37,16 +51,48 @@ export default class QueryBuilder extends Vue {
3751
children: [],
3852
};
3953
}
54+
55+
get queryBuiderConfig(): QueryBuilderConfig {
56+
if (!this.config.dragging) {
57+
return this.config;
58+
}
59+
60+
// Ensure group parameter is unique... otherwise query builder instances would be able to drag
61+
// across 2 different instances and this is currently not supported.
62+
return {
63+
...this.config,
64+
dragging: {
65+
handle: '.query-builder__draggable-handle',
66+
...this.config.dragging,
67+
group: `${new Date().getTime() * Math.random()}`,
68+
},
69+
};
70+
}
71+
72+
updateQuery(newQuery: RuleSet): void {
73+
this.trap = null;
74+
this.$emit('input', { ...newQuery });
75+
}
76+
77+
provideMergeTrap(): MergeTrap {
78+
if (this.trap) {
79+
return this.trap;
80+
}
81+
82+
this.trap = new MergeTrap();
83+
84+
return this.trap;
85+
}
4086
}
4187
</script>
4288

4389
<template>
4490
<query-builder-group
45-
:config="config"
91+
:config="queryBuiderConfig"
4692
:query="ruleSet"
4793
:depth="0"
4894
class="query-builder__root"
49-
@query-update="$emit('input', $event)"
95+
@query-update="updateQuery"
5096
>
5197
<template
5298
v-for="(_, slotName) in $scopedSlots"

src/QueryBuilderChild.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export default class QueryBuilderChild extends Vue {
4747
return ruleDefinition || null;
4848
}
4949
50-
get component(): VueComponent | string {
50+
get component(): VueComponent {
5151
if (this.isRule && this.ruleDefinition) {
5252
return QueryBuilderRule;
5353
}
@@ -103,7 +103,7 @@ export default class QueryBuilderChild extends Vue {
103103
</div>
104104
</template>
105105

106-
<style lang="scss">
106+
<style lang="scss" scoped>
107107
.query-builder-child {
108108
display: flex;
109109
flex-flow: row;

0 commit comments

Comments
 (0)