Skip to content

Commit 5b672de

Browse files
authored
Merge pull request #38 from scottdurow/custom-sort-position
feat: Custom sort position
2 parents 80c1fe2 + da89534 commit 5b672de

16 files changed

+5013
-4521
lines changed

code-component/.vscode/settings.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@
55
"sonarjs",
66
"sortablejs",
77
"Unchoose"
8-
]
8+
],
9+
"jest.coverageFormatter": "DefaultFormatter",
10+
"jest.showCoverageOnLoad": false
911
}

code-component/PowerDragDrop/ControlManifest.Input.xml

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<manifest>
3-
<control namespace="CustomControl" constructor="PowerDragDrop" version="1.0.29" display-name-key="PowerDragDrop" description-key="PowerDragDrop_Desc" control-type="standard">
3+
<control namespace="CustomControl" constructor="PowerDragDrop" version="1.0.32" display-name-key="PowerDragDrop" description-key="PowerDragDrop_Desc" control-type="standard">
44
<!--external-service-usage node declares whether this 3rd party PCF control is using external service or not, if yes, this control will be considered as premium and please also add the external domain it is using.-->
55
<external-service-usage enabled="false"></external-service-usage>
66
<property name="DropZoneID" description-key="DropZoneID_Desc" display-name-key="DropZoneID" required="true" usage="input" of-type="SingleLine.Text"/>
@@ -51,6 +51,21 @@
5151
<value name="Yes" display-name-key="Yes">1</value>
5252
<value name="TouchOnly" display-name-key="TouchOnly">2</value>
5353
</property>
54+
55+
<property name="SortDirection" description-key="SortDirection_Desc" display-name-key="SortDirection" of-type="Enum" usage="bound" required="false" default-value="0">
56+
<value name="Ascending" display-name-key="Ascending">0</value>
57+
<value name="Descending" display-name-key="Descending">1</value>
58+
</property>
59+
60+
<property name="SortPositionType" description-key="SortPositionType_Desc" display-name-key="SortPositionType" of-type="Enum" usage="bound" required="false" default-value="0">
61+
<value name="Index" display-name-key="Index">0</value>
62+
<value name="Custom" display-name-key="Custom">1</value>
63+
</property>
64+
65+
<property name="CustomSortIncrement" description-key="CustomSortIncrement_Desc" display-name-key="CustomSortIncrement" of-type="Whole.None" usage="bound" default-value="1000" />
66+
<property name="CustomSortMinIncrement" description-key="CustomSortMinIncrement" display-name-key="CustomSortMinIncrement" of-type="Whole.None" usage="bound" default-value="10" />
67+
<property name="CustomSortDecimalPlaces" description-key="CustomSortDecimalPlaces_Desc" display-name-key="CustomSortDecimalPlaces" of-type="Whole.None" usage="bound" default-value="4" />
68+
<property name="CustomSortAllowNegative" description-key="CustomSortAllowNegative_Desc" display-name-key="CustomSortAllowNegative" required="false" usage="bound" of-type="TwoOptions" default-value="true"/>
5469
<property name="Trace" description-key="Trace_Desc" display-name-key="Trace" usage="input" of-type="TwoOptions"/>
5570

5671
<!-- OnDrop Output Properties -->
@@ -75,6 +90,7 @@
7590
<data-set name="items" description-key="items_Desc" display-name-key="items">
7691
<property-set name="IdColumn" display-name-key="IdColumn" of-type="SingleLine.Text" usage="bound" required="false" />
7792
<property-set name="ZoneColumn" display-name-key="ZoneColumn" of-type="SingleLine.Text" usage="bound" required="false" />
93+
<property-set name="CustomPositionColumn" display-name-key="CustomPositionColumn" of-type="Decimal" usage="bound" required="false" />
7894
</data-set>
7995

8096
<property-dependencies>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/*
2+
This objective of this sorting algorithm is to allow cards to be moved and result in the minimum number of updates
3+
E.g. if a card is moved between to cards, the other cards should not be updated
4+
only the moved card should be updated to be half the distance between the two cards
5+
6+
It is used when the 'Custom Sort Order' is selected on the control, and an attribute
7+
is provided that holds the sort position value
8+
*/
9+
10+
import { CurrentItem } from './CurrentItemSchema';
11+
12+
// Only the ItemId, OriginalDropZoneId & DropZoneId are required to re-order and detect changes
13+
export type ReOrderableItem = Pick<CurrentItem, 'ItemId' | 'OriginalDropZoneId' | 'DropZoneId'> &
14+
Partial<Pick<CurrentItem, 'Position' | 'OriginalPosition' | 'HasMovedZone' | 'HasMovedPosition'>> & {
15+
Index?: number;
16+
};
17+
18+
export interface CustomSortPositionsOptions {
19+
// The direction that custom positions are sorted to establish if they are in sequence or not
20+
sortOrder: 'asc' | 'desc';
21+
// The amount items will be incremented by if there is space
22+
positionIncrement: number;
23+
// Allow custom sort positions to be negative when moving items below items that have a position less than the increment
24+
allowNegative: boolean;
25+
// Round to nearest integer - if true this will mean that multiple items can have the same position
26+
maxDecimalPlaces: number;
27+
// The minimum increment that will be used when ordering items. Below this, then positionIncrement/10 will be used
28+
minimumIncrement?: number;
29+
}
30+
31+
const defaultConfig = {
32+
positionIncrement: 100,
33+
sortOrder: 'asc',
34+
allowNegative: false,
35+
maxDecimalPlaces: 4,
36+
} as CustomSortPositionsOptions;
37+
38+
export class CustomSortPositionStrategy {
39+
private config = defaultConfig;
40+
private items: Partial<ReOrderableItem>[] = [];
41+
42+
public SetOptions(options?: Partial<CustomSortPositionsOptions>) {
43+
this.config = { ...defaultConfig, ...options };
44+
}
45+
public updateSortPosition(itemsToSort: Partial<ReOrderableItem>[]) {
46+
this.items = itemsToSort;
47+
48+
let firstPositionValue = this.getFirstPositionValue();
49+
50+
this.items.forEach((item, index) => {
51+
// Has the position already been set in a previous loop iteration? If so, skip
52+
if (item.Position !== undefined) {
53+
firstPositionValue = Math.max(firstPositionValue, item.Position);
54+
item.HasMovedPosition = item.Position !== item.OriginalPosition;
55+
item.HasMovedZone = item.DropZoneId !== item.OriginalDropZoneId;
56+
return;
57+
}
58+
59+
const previousItem = index === 0 ? null : this.items[index - 1];
60+
const previousPosition = this.getPreviousPosition(previousItem, firstPositionValue);
61+
const nextItem = this.getNextNonOutOfSequenceItem(index, previousPosition);
62+
const isPreviousOutOfSequence = this.isItemOutOfSequence(item, 'previous', previousPosition);
63+
const isNextOutOfSequence = this.isItemOutOfSequence(item, 'next', nextItem?.OriginalPosition);
64+
65+
if (isPreviousOutOfSequence || isNextOutOfSequence || item.OriginalPosition === undefined) {
66+
const subIncrement = this.getSubIncrement(index, nextItem, previousItem, previousPosition);
67+
let newPosition = previousPosition;
68+
// Set the position of the item and all items up until the next out of sequence item
69+
const endIndex = nextItem?.Index ?? this.items.length;
70+
for (let i = index; i < endIndex; i++) {
71+
newPosition += subIncrement;
72+
this.items[i].Position = Number(newPosition.toFixed(this.config.maxDecimalPlaces));
73+
}
74+
} else {
75+
item.Position = item.OriginalPosition;
76+
}
77+
78+
firstPositionValue = Math.max(firstPositionValue, item.Position ?? 0);
79+
item.HasMovedPosition = item.Position !== item.OriginalPosition;
80+
item.HasMovedZone = item.DropZoneId !== item.OriginalDropZoneId;
81+
});
82+
83+
return this.items;
84+
}
85+
86+
private getFirstPositionValue() {
87+
let firstPositionValue = 0;
88+
if (this.config.sortOrder === 'desc') {
89+
const firstDecreasingItem = this.items.find((item, index) => {
90+
if (index === this.items.length - 1) {
91+
return false; // skip last item
92+
}
93+
return (item.OriginalPosition ?? 0) > (this.items[index + 1].OriginalPosition ?? 0);
94+
});
95+
firstPositionValue = firstDecreasingItem?.OriginalPosition
96+
? firstDecreasingItem.OriginalPosition +
97+
this.config.positionIncrement * (this.items.indexOf(firstDecreasingItem) + 1)
98+
: this.config.positionIncrement * (this.items.length + 1);
99+
}
100+
return firstPositionValue;
101+
}
102+
103+
private getSubIncrement(
104+
index: number,
105+
nextItem: Partial<ReOrderableItem> | null,
106+
previousItem: Partial<ReOrderableItem> | null,
107+
previousPosition: number,
108+
) {
109+
const sortDirectionMultiplier = this.config.sortOrder === 'asc' ? 1 : -1;
110+
const increment = this.config.positionIncrement * sortDirectionMultiplier;
111+
const numberOfItemsBetweenOrEnd = (nextItem?.Index ?? this.items.length) - index;
112+
const nextPosition = nextItem?.OriginalPosition ?? previousPosition + numberOfItemsBetweenOrEnd * increment;
113+
114+
let subIncrement = (nextPosition - previousPosition) / (numberOfItemsBetweenOrEnd + (nextItem ? 1 : 0));
115+
116+
// Special case for when we are sequencing all the way to the end of the list
117+
if (this.config.sortOrder === 'desc' && !nextItem && previousItem) {
118+
subIncrement = increment / (numberOfItemsBetweenOrEnd + 1);
119+
}
120+
121+
if (this.config.minimumIncrement && Math.abs(subIncrement) < this.config.minimumIncrement) {
122+
subIncrement = this.config.minimumIncrement * 2 * sortDirectionMultiplier;
123+
}
124+
125+
// Special case when we do not allow negative numbers, the increment is squashed
126+
// This can result in duplicate positions
127+
if (
128+
!this.config.allowNegative &&
129+
this.config.sortOrder === 'desc' &&
130+
previousPosition + numberOfItemsBetweenOrEnd * subIncrement <= 0
131+
) {
132+
subIncrement = -previousPosition / (numberOfItemsBetweenOrEnd + 1);
133+
}
134+
135+
return subIncrement;
136+
}
137+
138+
private getNextNonOutOfSequenceItem(index: number, previousPosition: number) {
139+
const nextItemIndexRelative = this.items
140+
.slice(index + 1)
141+
.findIndex(
142+
(i) =>
143+
i.OriginalPosition &&
144+
(this.config.sortOrder === 'asc'
145+
? i.OriginalPosition > previousPosition
146+
: i.OriginalPosition < previousPosition),
147+
);
148+
const nextItemIndexAbsolute =
149+
nextItemIndexRelative > -1 ? index + 1 + nextItemIndexRelative : this.items.length;
150+
const nextItem = nextItemIndexRelative > -1 ? this.items[nextItemIndexAbsolute] : null;
151+
if (nextItem) nextItem.Index = nextItemIndexAbsolute;
152+
return nextItem;
153+
}
154+
155+
private getPreviousPosition(previousItem: Partial<ReOrderableItem> | null, firstPositionValue: number) {
156+
return previousItem?.Position ?? previousItem?.OriginalPosition ?? firstPositionValue;
157+
}
158+
159+
private isItemOutOfSequence(
160+
item: Partial<ReOrderableItem>,
161+
direction: 'previous' | 'next',
162+
comparePosition?: number,
163+
) {
164+
return (
165+
item.OriginalPosition &&
166+
comparePosition !== undefined &&
167+
((this.config.sortOrder === 'asc' && direction === 'next') ||
168+
(this.config.sortOrder === 'desc' && direction === 'previous')
169+
? item.OriginalPosition >= comparePosition
170+
: item.OriginalPosition <= comparePosition)
171+
);
172+
}
173+
}

0 commit comments

Comments
 (0)