Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Release Notes for Craft CMS 6.0 (WIP)

### Administration
- Single sections are now individual entry sources, rather than sharing one combined “Singles” source. Each single can be given its own place in the control panel navigation and its own index page, and gets its own breadcrumb when edited.
- Entry breadcrumbs now show a single’s section name even when its source is disabled, where previously no section crumb was shown at all.
- Added support for Markdown-based custom Dashboard widgets in the application's `resources/widgets/` directory. ([#19319](https://github.com/craftcms/cms/pull/19319))
- Added support for configuring the system time zone during installation. ([#18794](https://github.com/craftcms/cms/pull/18794))
- Added the `compiledTemplatesPath` config setting. ([#18861](https://github.com/craftcms/cms/pull/18861))
Expand Down
14 changes: 2 additions & 12 deletions packages/craftcms-legacy/cp/src/js/EntryIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ Craft.EntryIndex = Craft.BaseElementIndex.extend({
this.settings.context === 'index' &&
typeof defaultSectionHandle !== 'undefined'
) {
if (defaultSectionHandle === 'singles') {
return 'singles';
}

for (let i = 0; i < this.$sources.length; i++) {
const $source = $(this.$sources[i]);
if ($source.data('handle') === defaultSectionHandle) {
Expand All @@ -58,15 +54,9 @@ Craft.EntryIndex = Craft.BaseElementIndex.extend({
return;
}

let sectionHandle, entryTypeHandle;

// Get the handle of the selected source
if (this.$source.data('key') === 'singles') {
sectionHandle = 'singles';
} else {
sectionHandle = this.$source.data('handle');
entryTypeHandle = this.$source.data('entry-type');
}
const sectionHandle = this.$source.data('handle');
const entryTypeHandle = this.$source.data('entry-type');

// Update the New Entry button
// ---------------------------------------------------------------------
Expand Down
197 changes: 197 additions & 0 deletions resources/js/common/composables/useDragAndDrop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import {beforeEach, expect, it, vi} from 'vite-plus/test';
import {type DragData, useDragAndDrop} from './useDragAndDrop';

type Config = Record<string, any>;

const registry = vi.hoisted(() => ({
draggables: [] as Config[],
dropTargets: [] as Config[],
monitors: [] as Config[],
}));

vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({
draggable: (config: Config) => {
registry.draggables.push(config);

return () => undefined;
},
dropTargetForElements: (config: Config) => {
registry.dropTargets.push(config);

return () => undefined;
},
monitorForElements: (config: Config) => {
registry.monitors.push(config);

return () => undefined;
},
}));

vi.mock('@atlaskit/pragmatic-drag-and-drop/combine', () => ({
combine:
(...cleanups: Array<() => void>) =>
() =>
cleanups.forEach((cleanup) => cleanup()),
}));

// The hitbox helpers need real geometry, which happy-dom doesn't have. Carry
// the edge on the data instead, so a test can say which one it means.
vi.mock('@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge', () => ({
attachClosestEdge: (data: Config) => data,
extractClosestEdge: (data: Config) => data.closestEdge ?? 'bottom',
}));

vi.mock(
'@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index',
() => ({
getReorderDestinationIndex: ({indexOfTarget}: {indexOfTarget: number}) =>
indexOfTarget,
})
);

vi.mock(
'@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source',
() => ({preserveOffsetOnSource: () => () => ({x: 0, y: 0})})
);

vi.mock(
'@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview',
() => ({setCustomNativeDragPreview: () => undefined})
);

/**
* A list of rows registered with the composable, with the pieces a drag would
* otherwise reach through the DOM exposed for a test to drive.
*/
function list(
ids: string[],
options: Partial<Parameters<typeof useDragAndDrop>[0]> = {}
) {
const onReorder = vi.fn();
const dnd = useDragAndDrop({onReorder, ...options});

const rows = ids.map((id, index) => {
const before = registry.dropTargets.length;
dnd.registerItem(document.createElement('div'), null, id, index);

const dropTarget = registry.dropTargets[before]!;
const draggable = registry.draggables[before]!;

return {
id,
/** What this row puts on the wire when dragged. */
payload: (): DragData => draggable.getInitialData(),
/** What a drop on this row reports back. */
data: (): DragData => dropTarget.getData({input: {}}),
canDrop: (data: DragData) => dropTarget.canDrop({source: {data}}),
dragEnter: (data: DragData, edge: string = 'bottom') =>
dropTarget.onDragEnter({
source: {data},
self: {data: {closestEdge: edge}},
}),
dropState: () => dnd.getDropState(id),
};
});

dnd.setupMonitor();
const monitor = registry.monitors[registry.monitors.length - 1]!;

return {
rows,
onReorder,
canMonitor: (data: DragData) => monitor.canMonitor({source: {data}}),
drop: (data: DragData, target: DragData) =>
monitor.onDrop({
source: {data},
location: {current: {dropTargets: [{data: target}]}},
}),
};
}

/** A sources list, whose rows can be dropped on a pages list. */
function sourcesList(ids: string[]) {
return list(ids, {dragData: (id) => ({sourceKey: id})});
}

function pagesList(ids: string[], onForeignDrop = vi.fn()) {
return {
...list(ids, {
canDropForeign: (data) => typeof data.sourceKey === 'string',
onForeignDrop,
}),
onForeignDrop,
};
}

beforeEach(() => {
registry.draggables.length = 0;
registry.dropTargets.length = 0;
registry.monitors.length = 0;
});

it('reorders within a list', () => {
const sources = sourcesList(['a', 'b', 'c']);

sources.drop(sources.rows[0]!.payload(), sources.rows[2]!.data());

expect(sources.onReorder).toHaveBeenCalledWith(0, 2);
});

it('reports a row dropped on another list instead of reordering', () => {
const sources = sourcesList(['section:a', 'section:b']);
const pages = pagesList(['Entries', 'Archive']);

const dragged = sources.rows[1]!.payload();
const page = pages.rows[1]!;

expect(page.canDrop(dragged)).toBe(true);
expect(pages.canMonitor(dragged)).toBe(true);

pages.drop(dragged, page.data());

// Where it landed, so the receiving list can insert rather than guess.
expect(pages.onForeignDrop).toHaveBeenCalledWith(dragged, {
id: 'Archive',
index: 1,
edge: 'bottom',
});
// The page list owns the meaning of the drop; the source list stays out of it.
expect(sources.onReorder).not.toHaveBeenCalled();
expect(pages.onReorder).not.toHaveBeenCalled();
});

it('leaves its own rows alone when they land on another list', () => {
const sources = sourcesList(['section:a', 'section:b']);
const pages = pagesList(['Entries', 'Archive']);

sources.drop(sources.rows[0]!.payload(), pages.rows[1]!.data());

expect(sources.onReorder).not.toHaveBeenCalled();
});

it('turns away a foreign row that the list does not accept', () => {
const sources = sourcesList(['section:a']);
const pages = pagesList(['Entries']);
// A page carries no source key, so the sources list won't take one, and the
// pages list won't take one from another pages list either.
const otherPages = pagesList(['Drafts']);

expect(sources.rows[0]!.canDrop(pages.rows[0]!.payload())).toBe(false);
expect(sources.canMonitor(pages.rows[0]!.payload())).toBe(false);
expect(otherPages.rows[0]!.canDrop(pages.rows[0]!.payload())).toBe(false);
});

it('marks where a foreign drag would land', () => {
const sources = sourcesList(['section:a']);
const pages = pagesList(['Entries', 'Archive']);

pages.rows[0]!.dragEnter(sources.rows[0]!.payload(), 'top');

expect(pages.rows[0]!.dropState()).toEqual({
type: 'is-over-foreign',
closestEdge: 'top',
});
// A row of its own list still gets the between-rows treatment.
pages.rows[0]!.dragEnter(pages.rows[1]!.payload());
expect(pages.rows[0]!.dropState().type).toBe('is-over');
});
91 changes: 87 additions & 4 deletions resources/js/common/composables/useDragAndDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,36 @@ export type DragState =
// States for items being dragged over
export type DropState =
| {type: 'idle'}
| {type: 'is-over'; closestEdge: Edge; draggingRect: DOMRect};
| {type: 'is-over'; closestEdge: Edge; draggingRect: DOMRect}
// A row from another list is over this one, and would land at closestEdge.
| {type: 'is-over-foreign'; closestEdge: Edge};

/** The extra data a list attaches to its rows, for other lists to read. */
export type DragData = Record<string, unknown>;

/** Where a row from another list was dropped, relative to this list's rows. */
export interface ForeignDropTarget {
id: string | number;
index: number;
edge: Edge | null;
}

export interface UseDragAndDropOptions {
onReorder: (startIndex: number, finishIndex: number) => void;
axis?: Axis;
allowedEdges?: Edge[];
/**
* Extra data to attach to a row's drag payload. Only another list reads it —
* reordering within this one goes by index.
*/
dragData?: (id: string | number, index: number) => DragData;
/**
* Whether a row dragged out of another list may be dropped onto a row of
* this one. Without it, foreign drags are ignored.
*/
canDropForeign?: (data: DragData) => boolean;
/** A foreign row was dropped on this list, at the given position. */
onForeignDrop?: (data: DragData, target: ForeignDropTarget) => void;
}

export interface UseDragAndDropReturn {
Expand Down Expand Up @@ -72,12 +96,22 @@ export function useDragAndDrop(
return data[itemDataKey] === true;
}

/** Whether the payload comes from a list other than this one. */
function isForeign(data: ElementDragPayload['data']): boolean {
return isItemData(data) && data.instanceId !== instanceId;
}

function canDropForeign(data: ElementDragPayload['data']): boolean {
return isForeign(data) && (options.canDropForeign?.(data) ?? false);
}

function getItemData(
id: string | number,
index: number,
rect: DOMRect
): ItemData {
return {
...options.dragData?.(id, index),
[itemDataKey]: true,
id,
index,
Expand Down Expand Up @@ -172,8 +206,9 @@ export function useDragAndDrop(
getIsSticky: () => true,
canDrop({source}) {
return (
source.data[itemDataKey] === true &&
source.data.instanceId === instanceId
(source.data[itemDataKey] === true &&
source.data.instanceId === instanceId) ||
canDropForeign(source.data)
);
},
getData({input}) {
Expand All @@ -189,6 +224,14 @@ export function useDragAndDrop(
onDragEnter({source, self}) {
if (!isItemData(source.data)) return;

if (isForeign(source.data)) {
const closestEdge = extractClosestEdge(self.data);
if (!closestEdge) return;

setDropState(id, {type: 'is-over-foreign', closestEdge});
return;
}

// Ignore if dragging over self
if (source.data.id === id) return;

Expand All @@ -204,6 +247,21 @@ export function useDragAndDrop(
onDrag({source, self}) {
if (!isItemData(source.data)) return;

if (isForeign(source.data)) {
const closestEdge = extractClosestEdge(self.data);
if (!closestEdge) return;

const current = getDropState(id);
if (
current.type !== 'is-over-foreign' ||
current.closestEdge !== closestEdge
) {
setDropState(id, {type: 'is-over-foreign', closestEdge});
}

return;
}

// Ignore if dragging over self
if (source.data.id === id) return;

Expand All @@ -228,6 +286,11 @@ export function useDragAndDrop(
onDragLeave({source}) {
if (!isItemData(source.data)) return;

if (isForeign(source.data)) {
setDropState(id, idleDropState);
return;
}

// If the dragged item is leaving itself, update its drag state
if (source.data.id === id) {
setDragState(id, {type: 'is-dragging-and-left-self'});
Expand All @@ -247,7 +310,10 @@ export function useDragAndDrop(
function setupMonitor(): () => void {
return monitorForElements({
canMonitor({source}) {
return isItemData(source.data) && source.data.instanceId === instanceId;
return (
(isItemData(source.data) && source.data.instanceId === instanceId) ||
canDropForeign(source.data)
);
},
onDrop({location, source}) {
const target = location.current.dropTargets[0];
Expand All @@ -258,6 +324,23 @@ export function useDragAndDrop(

if (!isItemData(sourceData) || !isItemData(targetData)) return;

if (isForeign(sourceData)) {
// A foreign row landed on this list. Only this list knows what that
// means, so it reports the position rather than reordering.
if (targetData.instanceId === instanceId) {
options.onForeignDrop?.(sourceData, {
id: targetData.id,
index: targetData.index,
edge: extractClosestEdge(targetData),
});
}

return;
}

// One of our rows was dropped onto another list, which reports it.
if (targetData.instanceId !== instanceId) return;

const startIndex = sourceData.index;
const indexOfTarget = targetData.index;
const closestEdgeOfTarget = extractClosestEdge(targetData);
Expand Down
Loading
Loading