Skip to content

Commit

Permalink
Merge pull request #38 from scottdurow/custom-sort-position
Browse files Browse the repository at this point in the history
feat: Custom sort position
  • Loading branch information
scottdurow authored May 30, 2023
2 parents 80c1fe2 + da89534 commit 5b672de
Show file tree
Hide file tree
Showing 16 changed files with 5,013 additions and 4,521 deletions.
4 changes: 3 additions & 1 deletion code-component/.vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@
"sonarjs",
"sortablejs",
"Unchoose"
]
],
"jest.coverageFormatter": "DefaultFormatter",
"jest.showCoverageOnLoad": false
}
18 changes: 17 additions & 1 deletion code-component/PowerDragDrop/ControlManifest.Input.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
<control namespace="CustomControl" constructor="PowerDragDrop" version="1.0.29" display-name-key="PowerDragDrop" description-key="PowerDragDrop_Desc" control-type="standard">
<control namespace="CustomControl" constructor="PowerDragDrop" version="1.0.32" display-name-key="PowerDragDrop" description-key="PowerDragDrop_Desc" control-type="standard">
<!--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.-->
<external-service-usage enabled="false"></external-service-usage>
<property name="DropZoneID" description-key="DropZoneID_Desc" display-name-key="DropZoneID" required="true" usage="input" of-type="SingleLine.Text"/>
Expand Down Expand Up @@ -51,6 +51,21 @@
<value name="Yes" display-name-key="Yes">1</value>
<value name="TouchOnly" display-name-key="TouchOnly">2</value>
</property>

<property name="SortDirection" description-key="SortDirection_Desc" display-name-key="SortDirection" of-type="Enum" usage="bound" required="false" default-value="0">
<value name="Ascending" display-name-key="Ascending">0</value>
<value name="Descending" display-name-key="Descending">1</value>
</property>

<property name="SortPositionType" description-key="SortPositionType_Desc" display-name-key="SortPositionType" of-type="Enum" usage="bound" required="false" default-value="0">
<value name="Index" display-name-key="Index">0</value>
<value name="Custom" display-name-key="Custom">1</value>
</property>

<property name="CustomSortIncrement" description-key="CustomSortIncrement_Desc" display-name-key="CustomSortIncrement" of-type="Whole.None" usage="bound" default-value="1000" />
<property name="CustomSortMinIncrement" description-key="CustomSortMinIncrement" display-name-key="CustomSortMinIncrement" of-type="Whole.None" usage="bound" default-value="10" />
<property name="CustomSortDecimalPlaces" description-key="CustomSortDecimalPlaces_Desc" display-name-key="CustomSortDecimalPlaces" of-type="Whole.None" usage="bound" default-value="4" />
<property name="CustomSortAllowNegative" description-key="CustomSortAllowNegative_Desc" display-name-key="CustomSortAllowNegative" required="false" usage="bound" of-type="TwoOptions" default-value="true"/>
<property name="Trace" description-key="Trace_Desc" display-name-key="Trace" usage="input" of-type="TwoOptions"/>

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

<property-dependencies>
Expand Down
173 changes: 173 additions & 0 deletions code-component/PowerDragDrop/CustomSortPositionStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
This objective of this sorting algorithm is to allow cards to be moved and result in the minimum number of updates
E.g. if a card is moved between to cards, the other cards should not be updated
only the moved card should be updated to be half the distance between the two cards
It is used when the 'Custom Sort Order' is selected on the control, and an attribute
is provided that holds the sort position value
*/

import { CurrentItem } from './CurrentItemSchema';

// Only the ItemId, OriginalDropZoneId & DropZoneId are required to re-order and detect changes
export type ReOrderableItem = Pick<CurrentItem, 'ItemId' | 'OriginalDropZoneId' | 'DropZoneId'> &
Partial<Pick<CurrentItem, 'Position' | 'OriginalPosition' | 'HasMovedZone' | 'HasMovedPosition'>> & {
Index?: number;
};

export interface CustomSortPositionsOptions {
// The direction that custom positions are sorted to establish if they are in sequence or not
sortOrder: 'asc' | 'desc';
// The amount items will be incremented by if there is space
positionIncrement: number;
// Allow custom sort positions to be negative when moving items below items that have a position less than the increment
allowNegative: boolean;
// Round to nearest integer - if true this will mean that multiple items can have the same position
maxDecimalPlaces: number;
// The minimum increment that will be used when ordering items. Below this, then positionIncrement/10 will be used
minimumIncrement?: number;
}

const defaultConfig = {
positionIncrement: 100,
sortOrder: 'asc',
allowNegative: false,
maxDecimalPlaces: 4,
} as CustomSortPositionsOptions;

export class CustomSortPositionStrategy {
private config = defaultConfig;
private items: Partial<ReOrderableItem>[] = [];

public SetOptions(options?: Partial<CustomSortPositionsOptions>) {
this.config = { ...defaultConfig, ...options };
}
public updateSortPosition(itemsToSort: Partial<ReOrderableItem>[]) {
this.items = itemsToSort;

let firstPositionValue = this.getFirstPositionValue();

this.items.forEach((item, index) => {
// Has the position already been set in a previous loop iteration? If so, skip
if (item.Position !== undefined) {
firstPositionValue = Math.max(firstPositionValue, item.Position);
item.HasMovedPosition = item.Position !== item.OriginalPosition;
item.HasMovedZone = item.DropZoneId !== item.OriginalDropZoneId;
return;
}

const previousItem = index === 0 ? null : this.items[index - 1];
const previousPosition = this.getPreviousPosition(previousItem, firstPositionValue);
const nextItem = this.getNextNonOutOfSequenceItem(index, previousPosition);
const isPreviousOutOfSequence = this.isItemOutOfSequence(item, 'previous', previousPosition);
const isNextOutOfSequence = this.isItemOutOfSequence(item, 'next', nextItem?.OriginalPosition);

if (isPreviousOutOfSequence || isNextOutOfSequence || item.OriginalPosition === undefined) {
const subIncrement = this.getSubIncrement(index, nextItem, previousItem, previousPosition);
let newPosition = previousPosition;
// Set the position of the item and all items up until the next out of sequence item
const endIndex = nextItem?.Index ?? this.items.length;
for (let i = index; i < endIndex; i++) {
newPosition += subIncrement;
this.items[i].Position = Number(newPosition.toFixed(this.config.maxDecimalPlaces));
}
} else {
item.Position = item.OriginalPosition;
}

firstPositionValue = Math.max(firstPositionValue, item.Position ?? 0);
item.HasMovedPosition = item.Position !== item.OriginalPosition;
item.HasMovedZone = item.DropZoneId !== item.OriginalDropZoneId;
});

return this.items;
}

private getFirstPositionValue() {
let firstPositionValue = 0;
if (this.config.sortOrder === 'desc') {
const firstDecreasingItem = this.items.find((item, index) => {
if (index === this.items.length - 1) {
return false; // skip last item
}
return (item.OriginalPosition ?? 0) > (this.items[index + 1].OriginalPosition ?? 0);
});
firstPositionValue = firstDecreasingItem?.OriginalPosition
? firstDecreasingItem.OriginalPosition +
this.config.positionIncrement * (this.items.indexOf(firstDecreasingItem) + 1)
: this.config.positionIncrement * (this.items.length + 1);
}
return firstPositionValue;
}

private getSubIncrement(
index: number,
nextItem: Partial<ReOrderableItem> | null,
previousItem: Partial<ReOrderableItem> | null,
previousPosition: number,
) {
const sortDirectionMultiplier = this.config.sortOrder === 'asc' ? 1 : -1;
const increment = this.config.positionIncrement * sortDirectionMultiplier;
const numberOfItemsBetweenOrEnd = (nextItem?.Index ?? this.items.length) - index;
const nextPosition = nextItem?.OriginalPosition ?? previousPosition + numberOfItemsBetweenOrEnd * increment;

let subIncrement = (nextPosition - previousPosition) / (numberOfItemsBetweenOrEnd + (nextItem ? 1 : 0));

// Special case for when we are sequencing all the way to the end of the list
if (this.config.sortOrder === 'desc' && !nextItem && previousItem) {
subIncrement = increment / (numberOfItemsBetweenOrEnd + 1);
}

if (this.config.minimumIncrement && Math.abs(subIncrement) < this.config.minimumIncrement) {
subIncrement = this.config.minimumIncrement * 2 * sortDirectionMultiplier;
}

// Special case when we do not allow negative numbers, the increment is squashed
// This can result in duplicate positions
if (
!this.config.allowNegative &&
this.config.sortOrder === 'desc' &&
previousPosition + numberOfItemsBetweenOrEnd * subIncrement <= 0
) {
subIncrement = -previousPosition / (numberOfItemsBetweenOrEnd + 1);
}

return subIncrement;
}

private getNextNonOutOfSequenceItem(index: number, previousPosition: number) {
const nextItemIndexRelative = this.items
.slice(index + 1)
.findIndex(
(i) =>
i.OriginalPosition &&
(this.config.sortOrder === 'asc'
? i.OriginalPosition > previousPosition
: i.OriginalPosition < previousPosition),
);
const nextItemIndexAbsolute =
nextItemIndexRelative > -1 ? index + 1 + nextItemIndexRelative : this.items.length;
const nextItem = nextItemIndexRelative > -1 ? this.items[nextItemIndexAbsolute] : null;
if (nextItem) nextItem.Index = nextItemIndexAbsolute;
return nextItem;
}

private getPreviousPosition(previousItem: Partial<ReOrderableItem> | null, firstPositionValue: number) {
return previousItem?.Position ?? previousItem?.OriginalPosition ?? firstPositionValue;
}

private isItemOutOfSequence(
item: Partial<ReOrderableItem>,
direction: 'previous' | 'next',
comparePosition?: number,
) {
return (
item.OriginalPosition &&
comparePosition !== undefined &&
((this.config.sortOrder === 'asc' && direction === 'next') ||
(this.config.sortOrder === 'desc' && direction === 'previous')
? item.OriginalPosition >= comparePosition
: item.OriginalPosition <= comparePosition)
);
}
}
Loading

0 comments on commit 5b672de

Please sign in to comment.