diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 727e2783cdb..224a5f6e6fd 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -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)) diff --git a/packages/craftcms-legacy/cp/src/js/EntryIndex.js b/packages/craftcms-legacy/cp/src/js/EntryIndex.js index ff59830abc1..644e03cf8d2 100644 --- a/packages/craftcms-legacy/cp/src/js/EntryIndex.js +++ b/packages/craftcms-legacy/cp/src/js/EntryIndex.js @@ -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) { @@ -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 // --------------------------------------------------------------------- diff --git a/resources/js/common/composables/useDragAndDrop.test.ts b/resources/js/common/composables/useDragAndDrop.test.ts new file mode 100644 index 00000000000..f7be18bc5ed --- /dev/null +++ b/resources/js/common/composables/useDragAndDrop.test.ts @@ -0,0 +1,197 @@ +import {beforeEach, expect, it, vi} from 'vite-plus/test'; +import {type DragData, useDragAndDrop} from './useDragAndDrop'; + +type Config = Record; + +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[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'); +}); diff --git a/resources/js/common/composables/useDragAndDrop.ts b/resources/js/common/composables/useDragAndDrop.ts index 1a24d921f4e..137ac85ea6c 100644 --- a/resources/js/common/composables/useDragAndDrop.ts +++ b/resources/js/common/composables/useDragAndDrop.ts @@ -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; + +/** 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 { @@ -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, @@ -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}) { @@ -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; @@ -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; @@ -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'}); @@ -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]; @@ -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); diff --git a/resources/js/common/composables/useReorderableItems.ts b/resources/js/common/composables/useReorderableItems.ts index cde620e7a09..9505e6e0554 100644 --- a/resources/js/common/composables/useReorderableItems.ts +++ b/resources/js/common/composables/useReorderableItems.ts @@ -9,12 +9,14 @@ import { } from 'vue'; import { type Axis, + type DragData, type DragState, type DropState, + type ForeignDropTarget, useDragAndDrop, } from './useDragAndDrop.js'; -export type {DragState, DropState}; +export type {DragData, DragState, DropState, ForeignDropTarget}; type ReorderableElement = Element | ComponentPublicInstance | null; @@ -23,6 +25,12 @@ export interface UseReorderableItemsOptions { onReorder: (startIndex: number, finishIndex: number) => void; enabled?: () => boolean; axis?: Axis; + /** Extra data to attach to a row's drag payload, for other lists to read. */ + dragData?: (id: string | number, index: number) => DragData; + /** Whether a row dragged out of another list may be dropped onto a row here. */ + canDropForeign?: (data: DragData) => boolean; + /** A foreign row was dropped on this list, at the given position. */ + onForeignDrop?: (data: DragData, target: ForeignDropTarget) => void; } export interface UseReorderableItemsReturn { @@ -49,6 +57,9 @@ export function useReorderableItems( useDragAndDrop({ onReorder: options.onReorder, axis: options.axis ?? 'vertical', + dragData: (id, index) => options.dragData?.(id, index) ?? {}, + canDropForeign: (data) => options.canDropForeign?.(data) ?? false, + onForeignDrop: (data, target) => options.onForeignDrop?.(data, target), }); function resolveElement(el: ReorderableElement): HTMLElement | null { diff --git a/resources/js/modules/elements/components/customize-sources/CustomSourceList.stories.ts b/resources/js/modules/elements/components/customize-sources/CustomSourceList.stories.ts index 2508338eaf8..6d5338a58dd 100644 --- a/resources/js/modules/elements/components/customize-sources/CustomSourceList.stories.ts +++ b/resources/js/modules/elements/components/customize-sources/CustomSourceList.stories.ts @@ -1,5 +1,5 @@ import type {Meta, StoryObj} from '@storybook/vue3-vite'; -import {ref} from 'vue'; +import {computed, ref} from 'vue'; import type {ActionItem} from '@/common/types'; import CustomSourceList from './CustomSourceList.vue'; @@ -181,3 +181,92 @@ export const LongLabels: Story = { LIST ), }; + +/** + * Two lists that trade rows: drag a source into the pages list and it becomes a + * page of its own, beside the pages it was dropped between. + * + * `dragData` says what a source row carries, `canDropForeign` lets the pages + * list take a row carrying it, and `foreign-drop` reports the index it landed + * at. Reordering inside either list is unaffected — a page dragged onto a page + * still just moves. + */ +export const AcrossLists: Story = { + render: () => ({ + components: {CustomSourceList}, + setup() { + const pages = ref(PAGES.map((page) => ({...page}))); + const sources = ref>( + SOURCES.map((source) => ({...source, page: 'Entries'})) + ); + const selectedPage = ref('Entries'); + const selectedSource = ref(SOURCES[0]!.id); + + // Only the selected page's sources are listed, so reordering has to act + // on indexes into the full list. + const visible = computed(() => + sources.value.filter((source) => source.page === selectedPage.value) + ); + + return { + pages, + visible, + selectedPage, + selectedSource, + itemId: (row: Row) => row.id, + label: (row: Row) => row.label, + icon: (row: Row) => row.icon ?? null, + dragData: (row: Row) => ({sourceKey: row.id, sourceLabel: row.label}), + canDropForeign: (data: Record) => + typeof data.sourceKey === 'string', + onForeignDrop: (data: Record, index: number) => { + const source = sources.value.find((row) => row.id === data.sourceKey); + if (!source) return; + + pages.value.splice(index, 0, {id: source.label, label: source.label}); + source.page = source.label; + selectedPage.value = source.label; + }, + onReorderSources: (from: number, to: number) => { + const items = visible.value; + const fromIndex = sources.value.indexOf(items[from]!); + const toIndex = sources.value.indexOf(items[to]!); + const [moved] = sources.value.splice(fromIndex, 1); + if (moved) sources.value.splice(toIndex, 0, moved); + }, + onReorderPages: (from: number, to: number) => { + const [moved] = pages.value.splice(from, 1); + if (moved) pages.value.splice(to, 0, moved); + }, + }; + }, + template: ` +
+
+ +
+
+ +
+
+ `, + }), +}; diff --git a/resources/js/modules/elements/components/customize-sources/CustomSourceList.vue b/resources/js/modules/elements/components/customize-sources/CustomSourceList.vue index 6a51e27cd7f..16543f9a875 100644 --- a/resources/js/modules/elements/components/customize-sources/CustomSourceList.vue +++ b/resources/js/modules/elements/components/customize-sources/CustomSourceList.vue @@ -9,7 +9,11 @@ import {CraftActionItem, t} from '@craftcms/ui'; import ActionMenu from '@/common/components/ActionMenu.vue'; import type {ActionItem} from '@/common/types'; - import {useReorderableItems} from '@/common/composables/useReorderableItems'; + import type {Edge} from '@atlaskit/pragmatic-drag-and-drop-hitbox/types'; + import { + type DragData, + useReorderableItems, + } from '@/common/composables/useReorderableItems'; import PageIcon from './PageIcon.vue'; import VarDump from '@/common/components/VarDump.vue'; @@ -33,11 +37,24 @@ disabled?: (item: T) => boolean; /** A row's action menu. Returning nothing renders no menu. */ actions?: (item: T, index: number) => ActionItem[]; + /** + * What a row's drag carries for the other list to read — a source key, say. + * Rows this returns nothing for can still be reordered here, they just + * can't be dropped anywhere else. + */ + dragData?: (item: T, index: number) => DragData; + /** + * Whether a row dragged out of the other list can be dropped into this + * one, which emits `foreign-drop` rather than reordering. + */ + canDropForeign?: (data: DragData) => boolean; }>(); const emit = defineEmits<{ (e: 'select', id: string): void; (e: 'reorder', from: number, to: number): void; + /** `index` is where the row would be inserted, 0 through `items.length`. */ + (e: 'foreign-drop', data: DragData, index: number): void; }>(); // Resolved once per row rather than per binding: the id is read five times in @@ -60,13 +77,44 @@ emit('reorder', from, to); } + function rowById(id: string | number) { + return rows.value.find((row) => row.id === String(id)); + } + const {setItemRef, setHandleRef, getDragState, getDropState, getRowPosition} = useReorderableItems({ getItemIds: () => rows.value.map((row) => row.id), onReorder: reorder, - enabled: () => props.items.length > 1, + // A lone row has nothing to reorder with, but it can still be dragged + // over to the other list, and be dropped on. + enabled: () => + props.items.length > 1 || + props.dragData !== undefined || + props.canDropForeign !== undefined, + dragData: (id, index) => { + const row = rowById(id); + + return row ? (props.dragData?.(row.item, index) ?? {}) : {}; + }, + canDropForeign: (data) => props.canDropForeign?.(data) ?? false, + onForeignDrop: (data, target) => + emit( + 'foreign-drop', + data, + target.index + (target.edge === 'bottom' ? 1 : 0) + ), }); + /** + * Which side of a row a foreign drag would land on, so the insertion point is + * marked the way it will be applied. + */ + function foreignDropEdge(id: string): Edge | null { + const state = getDropState(id); + + return state.type === 'is-over-foreign' ? state.closestEdge : null; + } + function select(item: T, id: string): void { if (props.disabled?.(item)) return; @@ -86,6 +134,8 @@ 'cs-item': true, 'cs-item--heading': row.type === 'heading', 'cs-item--dragging': getDragState(row.id).type === 'is-dragging', + 'cs-item--drop-before': foreignDropEdge(row.id) === 'top', + 'cs-item--drop-after': foreignDropEdge(row.id) === 'bottom', }" > row.key === key); - if (!source) return; + if (!source || source.page === page) return; source.page = page; // The source is no longer on the page being viewed. if (selectedKey.value === key) void select(initialSource()); } + /** + * Gives a source a page of its own, at `index` among the pages — what + * dragging it into the pages sidebar means. + * + * The page takes the source's name. If one by that name is already there the + * source just moves onto it, since two pages can't share a name. + */ + function promoteToPage(key: string, index: number): void { + const source = sources.value.find((row) => row.key === key); + if (!source) return; + + const name = source.label.trim(); + // A source with no name yet has nothing to name a page after. + if (!name) return; + + const existing = pages.value.find( + (page) => pageNameId(page.name) === pageNameId(name) + ); + + if (!existing) { + pages.value.splice(index, 0, {name, icon: null}); + } + + source.page = existing?.name ?? name; + selectedPage.value = source.page; + void select(key); + } + function reorderPages(from: number, to: number): void { const [moved] = pages.value.splice(from, 1); if (moved) pages.value.splice(to, 0, moved); @@ -409,6 +437,7 @@ @add="addPage" @update="updatePage" @remove="removePage" + @promote="promoteToPage" /> diff --git a/resources/js/modules/elements/components/customize-sources/PagesSidebar.vue b/resources/js/modules/elements/components/customize-sources/PagesSidebar.vue index 6fa66f4d30b..98b994bcb62 100644 --- a/resources/js/modules/elements/components/customize-sources/PagesSidebar.vue +++ b/resources/js/modules/elements/components/customize-sources/PagesSidebar.vue @@ -2,6 +2,7 @@ import {ref} from 'vue'; import {t} from '@craftcms/ui'; import type {ActionItem} from '@/common/types'; + import type {DragData} from '@/common/composables/useReorderableItems'; import CustomSourceList from './CustomSourceList.vue'; import PageSettingsModal from './PageSettingsModal.vue'; import {pageNameId, type PageRow} from './types'; @@ -17,6 +18,7 @@ (e: 'add', name: string, icon: string | null): void; (e: 'update', page: PageRow, name: string, icon: string | null): void; (e: 'remove', page: PageRow): void; + (e: 'promote', key: string, index: number): void; }>(); const editing = ref(null); @@ -59,6 +61,21 @@ return clash ? t('Another page already has that name.') : null; } + /** + * A source dragged in here becomes a page of its own, beside the pages it's + * dropped between — not a source *on* one of them, which is what the source's + * own “Move to …” actions do. + */ + function canDropForeign(data: DragData): boolean { + return typeof data.sourceKey === 'string'; + } + + function onForeignDrop(data: DragData, index: number): void { + if (typeof data.sourceKey !== 'string') return; + + emit('promote', data.sourceKey, index); + } + function actions(page: PageRow): ActionItem[] { return [ {label: t('Settings'), onClick: () => open(page)}, @@ -85,8 +102,10 @@ :icon="icon" :selected="selected" :actions="actions" + :can-drop-foreign="canDropForeign" @select="(name) => emit('select', name)" @reorder="(from, to) => emit('reorder', from, to)" + @foreign-drop="onForeignDrop" />
diff --git a/resources/js/modules/elements/components/customize-sources/SourcesSidebar.vue b/resources/js/modules/elements/components/customize-sources/SourcesSidebar.vue index 790fe40ddd4..7f9ce8b03a7 100644 --- a/resources/js/modules/elements/components/customize-sources/SourcesSidebar.vue +++ b/resources/js/modules/elements/components/customize-sources/SourcesSidebar.vue @@ -3,6 +3,7 @@ import {ButtonVariant, t} from '@craftcms/ui'; import ActionMenu from '@/common/components/ActionMenu.vue'; import type {ActionItem} from '@/common/types'; + import type {DragData} from '@/common/composables/useReorderableItems'; import CustomSourceList from './CustomSourceList.vue'; import type {PageRow, SourceRow, SourceType} from './types'; @@ -45,6 +46,18 @@ return source.key ?? `unkeyed-${index}`; } + /** + * What the pages sidebar reads off a source dragged into it, to give the + * source a page of its own. A keyless source can't be moved, and a page + * holding nothing but a heading isn't shown at all, so neither carries + * anything and the pages list won't take them. + */ + function dragData(source: SourceRow): DragData { + return source.key && source.type !== 'heading' + ? {sourceKey: source.key, sourceLabel: source.label} + : {}; + } + function unkeyed(source: SourceRow): boolean { return !source.key; } @@ -94,6 +107,7 @@ :selected="selectedKey" :disabled="unkeyed" :actions="actions" + :drag-data="dragData" @select="(key) => emit('select', key)" @reorder="onReorder" /> diff --git a/src/Cp/Navigation.php b/src/Cp/Navigation.php index c0de0690432..b4350bcf13d 100644 --- a/src/Cp/Navigation.php +++ b/src/Cp/Navigation.php @@ -10,7 +10,10 @@ use CraftCms\Cms\Edition; use CraftCms\Cms\Element\ElementSources; use CraftCms\Cms\Entry\Elements\Entry; +use CraftCms\Cms\Entry\Entries; use CraftCms\Cms\Plugin\Plugins; +use CraftCms\Cms\Section\Data\Section; +use CraftCms\Cms\Section\Enums\SectionType; use CraftCms\Cms\Support\Facades\Sections; use CraftCms\Cms\Support\Facades\Volumes; use CraftCms\Cms\Support\Str; @@ -33,6 +36,7 @@ public function __construct( private Utilities $utilities, private GeneralConfig $generalConfig, private ElementSources $elementSources, + private Entries $entries, ) {} /** @return NavItem[] */ @@ -53,6 +57,7 @@ public function getItems(): array if ($entryPages->isNotEmpty()) { $entryPageSettings = $this->elementSources->getPageSettings(Entry::class); + $singleUrls = $this->singleEntryPageUrls($entryPages); $navItems = $navItems->merge( $entryPages->map(fn (string $page) => new NavItem() @@ -61,7 +66,7 @@ public function getItems(): array fn (NavItem $item) => $item->label(t('Entries')), fn (NavItem $item) => $item->label(t($page, category: 'site')), ) - ->url(sprintf('content/%s', Str::slug($page))) + ->url($singleUrls[$page] ?? sprintf('content/%s', Str::slug($page))) ->icon($entryPageSettings[$page]['icon'] ?? 'newspaper') ) ); @@ -205,6 +210,69 @@ public function getItems(): array })->all(); } + /** + * The entry pages that hold nothing but a single Single, mapped to that + * entry's edit URL. + * + * Such a page's index would list the one entry the person was going to open + * anyway, so its nav item skips it. A Single that hasn't been created yet is + * left out, and its page keeps its index. + * + * @param Collection $pages + * @return array + */ + private function singleEntryPageUrls(Collection $pages): array + { + $handles = []; + + foreach ($pages as $page) { + $section = $this->pageSingleSection($page); + + if ($section !== null) { + $handles[$page] = $section->handle; + } + } + + if ($handles === []) { + return []; + } + + // One query for the lot, rather than one per page. + $entries = $this->entries->getSingleEntriesByHandle(array_values($handles)); + + return collect($handles) + ->map(fn (string $handle) => ($entries[$handle] ?? null)?->getCpEditUrl()) + ->filter() + ->all(); + } + + /** + * The Single section a page consists of, if that's all it holds. + */ + private function pageSingleSection(string $page): ?Section + { + $sources = $this->elementSources + ->getSources(Entry::class, page: $page) + ->reject(fn (array $source) => $source['type'] === ElementSources::TYPE_HEADING); + + if ($sources->count() !== 1) { + return null; + } + + $source = $sources->first(); + + if ( + ($source['type'] ?? null) !== ElementSources::TYPE_NATIVE || + ! preg_match('/^section:(.+)$/', (string) ($source['key'] ?? ''), $matches) + ) { + return null; + } + + $section = Sections::getSectionByUid($matches[1]); + + return $section?->type === SectionType::Single ? $section : null; + } + private function navItemPath(string $url): string { return $this->withoutCpTrigger((string) parse_url($url, PHP_URL_PATH)); diff --git a/src/Database/Migrations/2026_08_12_143000_drop_elementactivity_draftid_fk.php b/src/Database/Migrations/2026_08_12_143000_drop_elementactivity_draftid_fk.php index 97d773676a4..7389b16ec17 100644 --- a/src/Database/Migrations/2026_08_12_143000_drop_elementactivity_draftid_fk.php +++ b/src/Database/Migrations/2026_08_12_143000_drop_elementactivity_draftid_fk.php @@ -21,7 +21,12 @@ public function up(): void return; } - Schema::table(Table::ELEMENTACTIVITY, fn (Blueprint $table) => $table->dropForeign($foreignKey['name'])); + Schema::table(Table::ELEMENTACTIVITY, function (Blueprint $table) use ($foreignKey) { + // SQLite rebuilds the table to drop a foreign key, and matches it by column rather than by name + $table->dropForeign( + Schema::getConnection()->isSqlite() ? $foreignKey['columns'] : $foreignKey['name'], + ); + }); } /** diff --git a/src/Database/Migrations/2026_09_02_000000_split_singles_source.php b/src/Database/Migrations/2026_09_02_000000_split_singles_source.php new file mode 100644 index 00000000000..7af778a50ed --- /dev/null +++ b/src/Database/Migrations/2026_09_02_000000_split_singles_source.php @@ -0,0 +1,119 @@ +>|null $sources */ + $sources = $projectConfig->get($path); + + if (! is_array($sources)) { + return; + } + + $sources = array_values($sources); + $index = array_find_key($sources, fn ($source) => ($source['key'] ?? null) === 'singles'); + + if ($index === null) { + return; + } + + $replacement = $this->replacementSources($sources[$index], $projectConfig); + + array_splice($sources, $index, 1, $replacement); + + $muteEvents = $projectConfig->muteEvents; + $projectConfig->muteEvents = true; + + try { + $projectConfig->set($path, $sources, 'Split the “Singles” entry source into per-section sources'); + $projectConfig->saveModifiedConfigData(); + } finally { + $projectConfig->muteEvents = $muteEvents; + } + } + + public function down(): void + { + $this->output->error('2026_09_02_000000_split_singles_source cannot be reverted.'); + } + + /** + * Builds the rows that replace the aggregate `singles` row: a `Singles` + * heading plus one native source per Single section, each inheriting the + * replaced row's page and display settings. + * + * Single section UIDs come straight from project config rather than the + * `Sections` service, so the migration doesn't depend on service state. + * + * @param array $source + * @return array> + */ + private function replacementSources(array $source, ProjectConfig $projectConfig): array + { + $page = $source['page'] ?? null; + + $inherited = array_filter([ + 'tableAttributes' => $source['tableAttributes'] ?? null, + 'defaultSort' => $source['defaultSort'] ?? null, + 'defaultViewMode' => $source['defaultViewMode'] ?? null, + 'disabled' => $source['disabled'] ?? null, + ], fn (mixed $value) => $value !== null); + + $rows = []; + + /** @var array> $sections */ + $sections = $projectConfig->get(ProjectConfig::PATH_SECTIONS) ?? []; + + foreach ($sections as $uid => $section) { + if (($section['type'] ?? null) !== SectionType::Single->value) { + continue; + } + + $rows[] = array_filter([ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => "section:$uid", + 'page' => $page, + ...$inherited, + ], fn (mixed $value) => $value !== null); + } + + if ($rows === []) { + return []; + } + + // Stored headings render as-is, so keep this locale-independent rather + // than baking in whatever language the migration happened to run under. + // The key is what lets the “Customize sources” modal rename or move the + // heading, and keeps it from being dropped on the next save. + $heading = array_filter([ + 'type' => ElementSources::TYPE_HEADING, + 'key' => 'heading:'.Str::uuid()->toString(), + 'heading' => 'Singles', + 'page' => $page, + ], fn (mixed $value) => $value !== null); + + return [$heading, ...$rows]; + } +}; diff --git a/src/Entry/Elements/Entry.php b/src/Entry/Elements/Entry.php index 676be27d21f..60c1fe45932 100644 --- a/src/Entry/Elements/Entry.php +++ b/src/Entry/Elements/Entry.php @@ -396,17 +396,11 @@ protected static function defineSources(string $context): array } $sectionIds = []; - $singleSectionIds = []; $sectionsByType = []; foreach ($sections as $section) { $sectionIds[] = $section->id; - - if ($section->type === SectionType::Single) { - $singleSectionIds[] = $section->id; - } else { - $sectionsByType[$section->type->value][] = $section; - } + $sectionsByType[$section->type->value][] = $section; } $sources = [ @@ -421,19 +415,10 @@ protected static function defineSources(string $context): array ], ]; - if (! empty($singleSectionIds)) { - $sources[] = [ - 'key' => 'singles', - 'label' => t('Singles'), - 'criteria' => [ - 'sectionId' => $singleSectionIds, - 'editable' => $editable, - ], - 'defaultSort' => ['title', 'asc'], - ]; - } - + // Singles get the same per-section source as any other section type, so + // each one can carry its own handle, CP nav placement, and index page. $sectionTypes = [ + SectionType::Single->value => t('Singles'), SectionType::Channel->value => t('Channels'), SectionType::Structure->value => t('Structures'), ]; @@ -467,6 +452,8 @@ protected static function defineSources(string $context): array $source['structureId'] = $section->structureId; $structure = $section->structureId ? Structures::getStructureById($section->structureId) : null; $source['structureEditable'] = $user && $structure && $user->can('edit', $structure); + } elseif ($type === SectionType::Single->value) { + $source['defaultSort'] = ['title', 'asc']; } else { $source['defaultSort'] = ['postDate', 'desc']; } @@ -528,6 +515,8 @@ protected static function defineFieldLayouts(?string $source): array if ($source === '*') { $sections = Sections::getAllSections()->all(); } elseif ($source === 'singles') { + // The legacy `content/{page}/singles` index still spans every Single, + // so its field layouts — and the columns they make available — do too. $sections = Sections::getSectionsByType(SectionType::Single)->all(); } elseif ($source !== null && preg_match('/^section:(.+)$/', $source, $matches)) { $sections = array_filter([ @@ -791,7 +780,7 @@ protected static function defineDefaultTableAttributes(string $source): array $attributes[] = 'section'; } - if ($source !== 'singles') { + if (! self::isSingleSource($source)) { $attributes[] = 'postDate'; $attributes[] = 'expiryDate'; $attributes[] = 'authors'; @@ -802,6 +791,27 @@ protected static function defineDefaultTableAttributes(string $source): array return $attributes; } + /** + * Returns whether a source key resolves to a Single section — either a + * section source (`section:{uid}`) for a Single, or the legacy `singles` + * key that still backs the `content/{page}/singles` index. + * + * Singles have no meaningful post/expiry dates or authors, so their + * indexes leave those columns out by default. + */ + private static function isSingleSource(string $source): bool + { + if ($source === 'singles') { + return true; + } + + if (! preg_match('/^section:(.+)$/', $source, $matches)) { + return false; + } + + return Sections::getSectionByUid($matches[1])?->type === SectionType::Single; + } + /** @return array> */ #[Override] protected static function defineCardAttributes(): array @@ -1215,7 +1225,7 @@ protected function crumbs(): array ]; // Is the section’s source enabled? - $sourceKey = $section->type === SectionType::Single ? 'singles' : "section:$section->uid"; + $sourceKey = "section:$section->uid"; if (ElementSources::sourceExists(Entry::class, $sourceKey)) { $sections = Sections::getEditableSections(); @@ -1228,14 +1238,9 @@ protected function crumbs(): array // Filter out any sections that don’t have an enabled source / don’t belong in this page $sources = ElementSources::getSources(Entry::class, page: $page)->all(); $sourceKeys = array_flip(array_filter(array_map(fn (array $source) => $source['key'] ?? null, $sources))); - $sections = $sections->filter(function (Section $s) use ($sourceKeys) { - $key = $s->type === SectionType::Single ? 'singles' : "section:$s->uid"; - - return isset($sourceKeys[$key]); - }); + $sections = $sections->filter(fn (Section $s) => isset($sourceKeys["section:$s->uid"])); $sectionOptions = $sections - ->filter(fn (Section $s) => $s->type !== SectionType::Single) ->map(fn (Section $s) => [ 'type' => 'link', 'label' => $s->getUiLabel(), @@ -1243,19 +1248,8 @@ protected function crumbs(): array 'selected' => $s->id === $section->id, ]); - /** @var Section|null $firstSingle */ - $firstSingle = $sections->first(fn (Section $s) => $s->type === SectionType::Single); - if ($firstSingle) { - $sectionOptions->prepend([ - 'type' => 'link', - 'label' => t('Singles'), - 'href' => Url::cpUrl($firstSingle->getCpIndexUri()), - 'selected' => $section->type === SectionType::Single, - ]); - } - - // The crumb names whichever option is current — for a Single that's - // the “Singles” pseudo-option, not the single's own name. + // The crumb names whichever option is current — every section type, + // Singles included, is a peer option in the switcher. $current = $sectionOptions->first(fn (array $o) => $o['selected']) ?? $sectionOptions->first(); @@ -1273,7 +1267,7 @@ protected function crumbs(): array 'href' => $current['href'], ]; } - } elseif ($section->type !== SectionType::Single) { + } else { // Just show its name w/o a link $crumbs[] = [ 'label' => $section->getUiLabel(), diff --git a/src/Http/Controllers/Elements/ElementSourcesController.php b/src/Http/Controllers/Elements/ElementSourcesController.php index 414da98aa2a..f4087eedb36 100644 --- a/src/Http/Controllers/Elements/ElementSourcesController.php +++ b/src/Http/Controllers/Elements/ElementSourcesController.php @@ -12,6 +12,7 @@ use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\ProjectConfig\ProjectConfig; use CraftCms\Cms\Support\Facades\Conditions; +use CraftCms\Cms\Support\Str; use Illuminate\Http\JsonResponse; use Illuminate\Validation\Rule; use Symfony\Component\HttpFoundation\Response; @@ -32,19 +33,23 @@ public function show(ElementIndexRequest $request, ElementSources $elementSource return new JsonResponse([ 'multiPage' => $multiPage, 'sources' => $sources - ->map(fn (array $source) => [ - 'key' => $source['key'] ?? null, - 'type' => $source['type'], - 'label' => $source['label'] ?? null, - 'heading' => $source['heading'] ?? null, - 'page' => $multiPage ? ($source['page'] ?? $this->defaultPage($elementType)) : null, - // ElementSources synthesizes a keyless blank heading as a - // separator. Nothing can address it by Control path and - // store() can't save it, so it gets no Form. - 'form' => ($source['key'] ?? '') !== '' - ? $sourceForm->payload($elementType, $source) - : null, - ]) + ->map(function (array $source) use ($elementType, $sourceForm, $multiPage) { + $source = self::withHeadingKey($source); + + return [ + 'key' => $source['key'] ?? null, + 'type' => $source['type'], + 'label' => $source['label'] ?? null, + 'heading' => $source['heading'] ?? null, + 'page' => $multiPage ? ($source['page'] ?? $this->defaultPage($elementType)) : null, + // ElementSources synthesizes a keyless blank heading as a + // separator. Nothing can address it by Control path and + // store() can't save it, so it gets no Form. + 'form' => ($source['key'] ?? '') !== '' + ? $sourceForm->payload($elementType, $source) + : null, + ]; + }) ->values() ->all(), 'pageSettings' => $elementSources->getPageSettings($elementType), @@ -94,6 +99,37 @@ public function form(ElementIndexRequest $request, ElementSources $elementSource ]); } + /** + * Gives a labeled heading a key if it doesn't have one. + * + * Element types define their group headings without keys (Entry's + * “Singles”, “Channels”, and “Structures”, say), as did headings stored + * before the modal started keying them. A keyless row can't carry a Form or + * be posted back in `sourceOrder`, so it would be dropped from project + * config the first time its sources were customized. + * + * The blank heading {@see ElementSources::getSources()} synthesizes to + * separate customized sources from the rest is regenerated on every read, + * so it stays keyless and unsaveable. + * + * @param array $source + * @return array + */ + private static function withHeadingKey(array $source): array + { + if ( + ($source['type'] ?? null) !== ElementSources::TYPE_HEADING || + ($source['key'] ?? '') !== '' || + ($source['heading'] ?? '') === '' + ) { + return $source; + } + + $source['key'] = 'heading:'.Str::uuid()->toString(); + + return $source; + } + /** * The page a multi-page source falls back to. It's a project config key, so * it must not be localized. diff --git a/src/Http/ViewModels/EntryIndexViewModel.php b/src/Http/ViewModels/EntryIndexViewModel.php index 7ec0ae2b2c0..3dfcd58f3c3 100644 --- a/src/Http/ViewModels/EntryIndexViewModel.php +++ b/src/Http/ViewModels/EntryIndexViewModel.php @@ -4,17 +4,33 @@ namespace CraftCms\Cms\Http\ViewModels; +use CraftCms\Cms\Element\ElementSources; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Http\Requests\ElementIndexRequest; +use CraftCms\Cms\Section\Data\Section; +use CraftCms\Cms\Section\Enums\SectionType; use CraftCms\Cms\Section\Resources\SectionResource; use CraftCms\Cms\Support\Facades\Sections; use Override; +use function CraftCms\Cms\t; + /** * The Inertia payload for the entry index screen (`content/Index`). */ class EntryIndexViewModel extends ContentIndexViewModel { + /** + * The legacy URL segment (and source key) for the combined Singles index. + * + * Singles are individual `section:{uid}` sources now, so this is no longer + * a real source — see {@see sourceState()}. + */ + private const string SINGLES_KEY = 'singles'; + + /** @var array{0: ?string, 1: ?array}|null */ + private ?array $resolvedSinglesSource = null; + public function __construct( ElementIndexRequest $request, ?string $page = null, @@ -35,9 +51,12 @@ public function publishableSections(): array } /** - * Maps the section-handle route segment (e.g. `content/entries/blog`, - * `content/entries/singles`) to its source key (`singles` for Single - * sections, `section:{uid}` otherwise). + * Maps the section-handle route segment (e.g. `content/entries/blog`) to + * its `section:{uid}` source key. + * + * `content/{page}/singles` predates per-single sources; it keeps working as + * an index over every Single section through the {@see SINGLES_KEY} + * pseudo-source that {@see sourceState()} resolves. */ #[Override] protected function defaultSourceKey(): ?string @@ -46,12 +65,56 @@ protected function defaultSourceKey(): ?string return null; } - if ($this->sectionHandle === 'singles') { - return 'singles'; + if ($this->sectionHandle === self::SINGLES_KEY) { + return self::SINGLES_KEY; } $section = Sections::getSectionByHandle($this->sectionHandle); return $section ? "section:$section->uid" : null; } + + /** + * Resolves `singles` to a criteria-only pseudo-source spanning every + * editable Single section, so bookmarks and stored links to + * `content/{page}/singles` keep listing all singles on one page. + * + * @return array{0: ?string, 1: ?array} + */ + #[Override] + protected function sourceState(): array + { + if ($this->resolvedSinglesSource !== null) { + return $this->resolvedSinglesSource; + } + + $requestedSource = $this->request->input('source') ?? $this->defaultSourceKey(); + + if ($requestedSource !== self::SINGLES_KEY) { + return parent::sourceState(); + } + + $sectionIds = Sections::getEditableSections() + ->filter(fn (Section $section) => $section->type === SectionType::Single) + ->map(fn (Section $section) => $section->id) + ->values() + ->all(); + + // With no singles there's nothing to aggregate, so fall back to the + // regular resolution, which lands on the first available source. + if ($sectionIds === []) { + return parent::sourceState(); + } + + return $this->resolvedSinglesSource = [self::SINGLES_KEY, [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => self::SINGLES_KEY, + 'label' => t('Singles'), + 'criteria' => [ + 'sectionId' => $sectionIds, + 'editable' => true, + ], + 'defaultSort' => ['title', 'asc'], + ]]; + } } diff --git a/src/Section/Data/Section.php b/src/Section/Data/Section.php index bbc0c978ed2..9f192be790b 100644 --- a/src/Section/Data/Section.php +++ b/src/Section/Data/Section.php @@ -230,7 +230,7 @@ public function getCpIndexUri(): string return sprintf( 'content/%s/%s', $page ? Str::slug($page) : 'entries', - $this->type === SectionType::Single ? 'singles' : $this->handle, + $this->handle, ); } @@ -240,8 +240,7 @@ public function getCpIndexUri(): string public function getPage(): ?string { if (! isset($this->page)) { - $sourceKey = $this->type === SectionType::Single ? 'singles' : "section:$this->uid"; - $source = ElementSources::findSource(Entry::class, $sourceKey); + $source = ElementSources::findSource(Entry::class, "section:$this->uid"); $this->page = $source['page'] ?? false; } diff --git a/tests/Feature/Cp/NavigationTest.php b/tests/Feature/Cp/NavigationTest.php new file mode 100644 index 00000000000..92df6a42716 --- /dev/null +++ b/tests/Feature/Cp/NavigationTest.php @@ -0,0 +1,132 @@ +> $sources + */ +function storeEntrySources(array $sources): void +{ + app(ProjectConfig::class)->set( + sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCES, Entry::class), + $sources, + ); + + // ElementSources memoizes what it read before the config changed. + app()->forgetScopedInstances(); +} + +/** @return array */ +function entryNavUrls(): array +{ + return collect(app(Navigation::class)->getItems()) + ->mapWithKeys(fn (NavItem $item) => [$item->label => $item->url]) + ->all(); +} + +beforeEach(function () { + actingAs(User::findOne()); +}); + +it('links a page holding one single straight to the entry', function () { + $section = Section::factory()->create([ + 'type' => SectionType::Single, + 'name' => 'Home', + 'handle' => 'home', + ]); + $entry = EntryModel::factory()->forSection($section)->createElement(['title' => 'Home']); + + storeEntrySources([ + ['type' => ElementSources::TYPE_NATIVE, 'key' => '*', 'page' => 'Entries'], + ['type' => ElementSources::TYPE_NATIVE, 'key' => "section:$section->uid", 'page' => 'Home'], + ]); + + // The index would list the one entry you were going to open anyway. + expect(entryNavUrls()['Home'])->toBe($entry->getCpEditUrl()) + ->and(entryNavUrls()['Entries'])->toBe(Url::url('content/entries')); +}); + +it('keeps the index for a page holding more than the single', function () { + $home = Section::factory()->create([ + 'type' => SectionType::Single, + 'name' => 'Home', + 'handle' => 'home', + ]); + $posts = Section::factory()->create([ + 'type' => SectionType::Channel, + 'name' => 'Posts', + 'handle' => 'posts', + ]); + EntryModel::factory()->forSection($home)->createElement(['title' => 'Home']); + + storeEntrySources([ + ['type' => ElementSources::TYPE_NATIVE, 'key' => '*', 'page' => 'Entries'], + ['type' => ElementSources::TYPE_NATIVE, 'key' => "section:$home->uid", 'page' => 'Home'], + ['type' => ElementSources::TYPE_NATIVE, 'key' => "section:$posts->uid", 'page' => 'Home'], + ]); + + expect(entryNavUrls()['Home'])->toBe(Url::url('content/home')); +}); + +it('keeps the index for a page holding one channel', function () { + $posts = Section::factory()->create([ + 'type' => SectionType::Channel, + 'name' => 'Posts', + 'handle' => 'posts', + ]); + + storeEntrySources([ + ['type' => ElementSources::TYPE_NATIVE, 'key' => '*', 'page' => 'Entries'], + ['type' => ElementSources::TYPE_NATIVE, 'key' => "section:$posts->uid", 'page' => 'Posts'], + ]); + + expect(entryNavUrls()['Posts'])->toBe(Url::url('content/posts')); +}); + +it('ignores a heading sitting on the single’s page', function () { + $section = Section::factory()->create([ + 'type' => SectionType::Single, + 'name' => 'Home', + 'handle' => 'home', + ]); + $entry = EntryModel::factory()->forSection($section)->createElement(['title' => 'Home']); + + storeEntrySources([ + ['type' => ElementSources::TYPE_NATIVE, 'key' => '*', 'page' => 'Entries'], + ['type' => ElementSources::TYPE_HEADING, 'key' => 'heading:1', 'heading' => 'Singles', 'page' => 'Home'], + ['type' => ElementSources::TYPE_NATIVE, 'key' => "section:$section->uid", 'page' => 'Home'], + ]); + + expect(entryNavUrls()['Home'])->toBe($entry->getCpEditUrl()); +}); + +it('keeps the index while the single has no entry yet', function () { + $section = Section::factory()->create([ + 'type' => SectionType::Single, + 'name' => 'Home', + 'handle' => 'home', + ]); + + storeEntrySources([ + ['type' => ElementSources::TYPE_NATIVE, 'key' => '*', 'page' => 'Entries'], + ['type' => ElementSources::TYPE_NATIVE, 'key' => "section:$section->uid", 'page' => 'Home'], + ]); + + expect(entryNavUrls()['Home'])->toBe(Url::url('content/home')); +}); diff --git a/tests/Feature/Database/Migrations/SplitSinglesSourceTest.php b/tests/Feature/Database/Migrations/SplitSinglesSourceTest.php new file mode 100644 index 00000000000..1bd679d0fe3 --- /dev/null +++ b/tests/Feature/Database/Migrations/SplitSinglesSourceTest.php @@ -0,0 +1,172 @@ +projectConfig = app(ProjectConfig::class); + + $this->homeUid = '11111111-1111-1111-1111-111111111111'; + $this->aboutUid = '22222222-2222-2222-2222-222222222222'; + $this->blogUid = '33333333-3333-3333-3333-333333333333'; + + $this->projectConfig->set(ProjectConfig::PATH_SECTIONS, [ + $this->homeUid => ['name' => 'Home', 'handle' => 'home', 'type' => SectionType::Single->value], + $this->blogUid => ['name' => 'Blog', 'handle' => 'blog', 'type' => SectionType::Channel->value], + $this->aboutUid => ['name' => 'About', 'handle' => 'about', 'type' => SectionType::Single->value], + ]); +}); + +it('expands a stored singles row in place', function () { + $this->projectConfig->set(SplitSinglesSource::path(), [ + ['type' => ElementSources::TYPE_NATIVE, 'key' => '*', 'page' => 'Content'], + [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => 'singles', + 'page' => 'Content', + 'tableAttributes' => ['status', 'link'], + 'defaultSort' => ['title', 'asc'], + 'defaultViewMode' => 'cards', + ], + ['type' => ElementSources::TYPE_HEADING, 'heading' => 'Channels', 'page' => 'Content'], + ['type' => ElementSources::TYPE_NATIVE, 'key' => "section:{$this->blogUid}", 'page' => 'Content'], + ]); + + SplitSinglesSource::migration()->up(); + + $sources = $this->projectConfig->get(SplitSinglesSource::path()); + + expect(array_map(fn (array $s) => $s['heading'] ?? $s['key'], $sources))->toBe([ + '*', + 'Singles', + "section:{$this->homeUid}", + "section:{$this->aboutUid}", + 'Channels', + "section:{$this->blogUid}", + ]); +}); + +it('keys the Singles heading so the customize sources modal can keep it', function () { + $this->projectConfig->set(SplitSinglesSource::path(), [ + ['type' => ElementSources::TYPE_NATIVE, 'key' => 'singles'], + ]); + + SplitSinglesSource::migration()->up(); + + $heading = $this->projectConfig->get(SplitSinglesSource::path())[0]; + + expect($heading['key'])->toStartWith('heading:') + ->and(Str::isUuid(substr($heading['key'], strlen('heading:'))))->toBeTrue(); +}); + +it('inherits the replaced row’s page and display settings', function () { + $this->projectConfig->set(SplitSinglesSource::path(), [ + [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => 'singles', + 'page' => 'Pages', + 'tableAttributes' => ['status', 'link'], + 'defaultSort' => ['title', 'asc'], + 'defaultViewMode' => 'cards', + 'disabled' => true, + ], + ]); + + SplitSinglesSource::migration()->up(); + + $sources = $this->projectConfig->get(SplitSinglesSource::path()); + + // Project config sorts each row's keys, so compare loosely. + expect($sources[0])->toEqual([ + 'type' => ElementSources::TYPE_HEADING, + 'key' => $sources[0]['key'], + 'heading' => 'Singles', + 'page' => 'Pages', + ]); + + foreach ([$sources[1], $sources[2]] as $source) { + expect($source['page'])->toBe('Pages') + ->and($source['tableAttributes'])->toBe(['status', 'link']) + ->and($source['defaultSort'])->toBe(['title', 'asc']) + ->and($source['defaultViewMode'])->toBe('cards') + ->and($source['disabled'])->toBeTrue(); + } +}); + +it('is a no-op on a second run', function () { + $this->projectConfig->set(SplitSinglesSource::path(), [ + ['type' => ElementSources::TYPE_NATIVE, 'key' => '*'], + ['type' => ElementSources::TYPE_NATIVE, 'key' => 'singles'], + ]); + + SplitSinglesSource::migration()->up(); + $afterFirstRun = $this->projectConfig->get(SplitSinglesSource::path()); + + SplitSinglesSource::migration()->up(); + + expect($this->projectConfig->get(SplitSinglesSource::path()))->toBe($afterFirstRun); +}); + +it('leaves a config with no singles row untouched', function () { + $stored = [ + ['type' => ElementSources::TYPE_NATIVE, 'key' => '*'], + ['type' => ElementSources::TYPE_HEADING, 'heading' => 'Channels'], + ['type' => ElementSources::TYPE_NATIVE, 'key' => "section:{$this->blogUid}"], + ]; + + $this->projectConfig->set(SplitSinglesSource::path(), $stored); + + SplitSinglesSource::migration()->up(); + + expect($this->projectConfig->get(SplitSinglesSource::path()))->toEqual($stored); +}); + +it('is a no-op on a fresh install with no stored sources', function () { + $this->projectConfig->set(SplitSinglesSource::path(), null); + + SplitSinglesSource::migration()->up(); + + expect($this->projectConfig->get(SplitSinglesSource::path()))->toBeNull(); +}); + +it('restores project config event muting when the migration fails', function () { + $this->projectConfig->set(SplitSinglesSource::path(), [ + ['type' => ElementSources::TYPE_NATIVE, 'key' => 'singles'], + ]); + + /** @var ProjectConfig&MockInterface $projectConfig */ + $projectConfig = Mockery::mock(app(ProjectConfig::class))->makePartial(); + $projectConfig->muteEvents = false; + $projectConfig->shouldReceive('set') + ->once() + ->andThrow(new RuntimeException('Failed to update project config')); + app()->instance(ProjectConfig::class, $projectConfig); + + expect(fn () => SplitSinglesSource::migration()->up())->toThrow(RuntimeException::class) + ->and($projectConfig->muteEvents)->toBeFalse(); +}); diff --git a/tests/Feature/Element/Concerns/DisplayedInIndexTest.php b/tests/Feature/Element/Concerns/DisplayedInIndexTest.php index f0e056c2cd1..37bcf58d8b9 100644 --- a/tests/Feature/Element/Concerns/DisplayedInIndexTest.php +++ b/tests/Feature/Element/Concerns/DisplayedInIndexTest.php @@ -159,7 +159,7 @@ public static function hasStatuses(): bool expect($attributes)->toContain('link'); }); - test('returns default attributes for singles source', function () { + test('returns default attributes for the legacy singles source', function () { $attributes = Entry::defaultTableAttributes('singles'); expect($attributes)->toBeArray(); expect($attributes)->toContain('status'); @@ -176,19 +176,19 @@ public static function hasStatuses(): bool expect($attributes)->toContain('section'); }); - test('excludes section attribute for singles source', function () { + test('excludes section attribute for the legacy singles source', function () { $attributes = TestEntryForDisplayedInIndex::exposeDefineDefaultTableAttributes('singles'); expect($attributes)->not->toContain('section'); }); - test('excludes date and author attributes for singles source', function () { + test('excludes date and author attributes for the legacy singles source', function () { $attributes = TestEntryForDisplayedInIndex::exposeDefineDefaultTableAttributes('singles'); expect($attributes)->not->toContain('postDate'); expect($attributes)->not->toContain('expiryDate'); expect($attributes)->not->toContain('authors'); }); - test('includes date and author attributes for non-singles sources', function () { + test('includes date and author attributes for a non-single section source', function () { $attributes = TestEntryForDisplayedInIndex::exposeDefineDefaultTableAttributes('section:blog'); expect($attributes)->toContain('postDate'); expect($attributes)->toContain('expiryDate'); diff --git a/tests/Feature/Element/Concerns/HasSourcesTest.php b/tests/Feature/Element/Concerns/HasSourcesTest.php index 87345377587..8899e81dd8e 100644 --- a/tests/Feature/Element/Concerns/HasSourcesTest.php +++ b/tests/Feature/Element/Concerns/HasSourcesTest.php @@ -86,14 +86,14 @@ public static function displayName(): string expect($layouts)->toBeArray(); }); - test('returns array for singles source', function () { + test('returns array for a single’s section source', function () { $entryType = EntryType::factory()->create(); - Section::factory()->withEntryTypes($entryType)->create([ + $section = Section::factory()->withEntryTypes($entryType)->create([ 'type' => SectionType::Single, ]); - $layouts = Entry::fieldLayouts('singles'); + $layouts = Entry::fieldLayouts("section:{$section->uid}"); expect($layouts)->toBeArray() ->and($layouts)->not()->toBeEmpty(); diff --git a/tests/Feature/Entry/Elements/EntrySourcesTest.php b/tests/Feature/Entry/Elements/EntrySourcesTest.php new file mode 100644 index 00000000000..7b8b5cb0567 --- /dev/null +++ b/tests/Feature/Entry/Elements/EntrySourcesTest.php @@ -0,0 +1,173 @@ +|null */ + public static function find(string $key, string $context = 'index'): ?array + { + /** @var array|null */ + return collect(Entry::sources($context))->firstWhere('key', $key); + } +} + +describe('single sections as sources', function () { + test('emits one section source per single', function () { + $home = Section::factory()->create([ + 'type' => SectionType::Single, + 'name' => 'Homepage', + 'handle' => 'homePage', + ]); + $about = Section::factory()->create([ + 'type' => SectionType::Single, + 'name' => 'About', + 'handle' => 'aboutPage', + ]); + + expect(EntrySourceLookup::find("section:{$home->uid}"))->not->toBeNull() + ->and(EntrySourceLookup::find("section:{$about->uid}"))->not->toBeNull(); + }); + + test('no longer emits an aggregate singles source', function () { + Section::factory()->create(['type' => SectionType::Single]); + + expect(EntrySourceLookup::find('singles'))->toBeNull(); + }); + + test('carries the single’s handle and section data', function () { + $section = Section::factory()->create([ + 'type' => SectionType::Single, + 'name' => 'Homepage', + 'handle' => 'homePage', + ]); + + $source = EntrySourceLookup::find("section:{$section->uid}"); + + expect($source)->not->toBeNull() + ->and($source['label'])->toBe('Homepage') + ->and($source['data']['handle'])->toBe('homePage') + ->and($source['data']['type'])->toBe(SectionType::Single->value) + ->and($source['data']['section-id'])->toBe($section->id) + ->and($source['sites'])->toBeArray() + ->and($source['criteria']['sectionId'])->toBe($section->id); + }); + + test('sorts a single’s index by title, ascending', function () { + $section = Section::factory()->create(['type' => SectionType::Single]); + + expect(EntrySourceLookup::find("section:{$section->uid}")['defaultSort'])->toBe(['title', 'asc']); + }); + + test('groups singles under a Singles heading', function () { + $section = Section::factory()->create(['type' => SectionType::Single]); + + $sources = Entry::sources('index'); + $headingIndex = array_find_key($sources, fn ($source) => ($source['heading'] ?? null) === 'Singles'); + + expect($headingIndex)->not->toBeNull(); + + // Every row between this heading and the next one is a single. + $keysUnderHeading = []; + + for ($i = $headingIndex + 1; $i < count($sources); $i++) { + if (isset($sources[$i]['heading'])) { + break; + } + + $keysUnderHeading[] = $sources[$i]['key']; + } + + expect($keysUnderHeading)->toContain("section:{$section->uid}"); + }); + + test('stays a real source in the relation-field contexts', function (string $context) { + $section = Section::factory()->create(['type' => SectionType::Single]); + + expect(EntrySourceLookup::find("section:{$section->uid}", $context))->not->toBeNull(); + })->with(['modal', 'field', 'settings']); +}); + +describe('single index columns', function () { + test('omits post date, expiry date and authors for a single’s own source', function () { + $section = Section::factory()->create(['type' => SectionType::Single]); + + $attributes = Entry::defaultTableAttributes("section:{$section->uid}"); + + expect($attributes)->toContain('status') + ->and($attributes)->toContain('link') + ->and($attributes)->not->toContain('postDate') + ->and($attributes)->not->toContain('expiryDate') + ->and($attributes)->not->toContain('authors'); + }); + + test('keeps post date, expiry date and authors for a channel’s source', function () { + $section = Section::factory()->create(['type' => SectionType::Channel]); + + $attributes = Entry::defaultTableAttributes("section:{$section->uid}"); + + expect($attributes)->toContain('postDate') + ->and($attributes)->toContain('expiryDate') + ->and($attributes)->toContain('authors'); + }); +}); + +describe('single crumbs', function () { + test('names the single’s own section', function () { + actingAs(User::find()->one()); + + $section = Section::factory()->create([ + 'type' => SectionType::Single, + 'name' => 'Homepage', + 'handle' => 'homePage', + ]); + + $entry = EntryModel::factory()->forSection($section)->createElement(['title' => 'Homepage']); + + $crumbs = $entry->getCrumbs(); + + expect($crumbs[0]['label'])->toBe('Entries') + ->and($crumbs[1]['label'])->toBe('Homepage'); + }); + + test('names a single whose source is disabled', function () { + actingAs(User::find()->one()); + + $section = Section::factory()->create([ + 'type' => SectionType::Single, + 'name' => 'Hidden', + 'handle' => 'hiddenPage', + ]); + + $entry = EntryModel::factory()->forSection($section)->createElement(['title' => 'Hidden']); + + // Pretend the section has no source at all: the crumb falls back to the + // section's own name rather than disappearing. + Event::listen(function (ElementSourcesResolving $event) use ($section) { + if ($event->elementType === Entry::class) { + $event->sources = array_values(array_filter( + $event->sources, + fn (array $source) => ($source['key'] ?? null) !== "section:{$section->uid}", + )); + } + }); + + $crumbs = $entry->getCrumbs(); + + expect($crumbs[1]['label'])->toBe('Hidden') + ->and($crumbs[1])->not->toHaveKey('href'); + }); +}); diff --git a/tests/Feature/Http/Controllers/ContentIndexControllerTest.php b/tests/Feature/Http/Controllers/ContentIndexControllerTest.php index 4e4ee4d436a..abe97da2326 100644 --- a/tests/Feature/Http/Controllers/ContentIndexControllerTest.php +++ b/tests/Feature/Http/Controllers/ContentIndexControllerTest.php @@ -105,14 +105,14 @@ ); }); -it('scopes the Singles source to single sections only', function () { +it('scopes the legacy singles source to single sections only', function () { $single = Section::factory()->create(['type' => SectionType::Single]); $channel = Section::factory()->create(['type' => SectionType::Channel]); EntryModel::factory()->forSection($single)->create(); EntryModel::factory()->forSection($channel)->count(3)->create(); - // The Singles source must not spill the channel's entries into the list. + // The legacy singles alias must not spill the channel's entries into the list. get("/{$this->cpTrigger}/content/entries?".http_build_query([ 'source' => 'singles', 'viewMode' => 'cards', @@ -268,13 +268,35 @@ ); }); -it('selects the singles source for the singles handle', function () { - Section::factory()->create(['type' => SectionType::Single]); +it('selects the source for a single’s own section-handle URL', function () { + $section = Section::factory()->create([ + 'type' => SectionType::Single, + 'handle' => 'homePage', + ]); + + get("/{$this->cpTrigger}/content/entries/homePage") + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('source.key', "section:{$section->uid}") + ); +}); + +it('still resolves the legacy singles URL, listing every single', function () { + $home = Section::factory()->create(['type' => SectionType::Single]); + $about = Section::factory()->create(['type' => SectionType::Single]); + $channel = Section::factory()->create(['type' => SectionType::Channel]); + + EntryModel::factory()->forSection($home)->create(); + EntryModel::factory()->forSection($about)->create(); + EntryModel::factory()->forSection($channel)->count(3)->create(); - get("/{$this->cpTrigger}/content/entries/singles") + // `singles` is no longer a real source, but the URL stays bookmarkable as + // an index over every Single section. + get("/{$this->cpTrigger}/content/entries/singles?".http_build_query(['viewMode' => 'cards'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->where('source.key', 'singles') + ->where('pagination.total', 2) ); }); diff --git a/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php b/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php index f1196813c54..f35e2a3b10f 100644 --- a/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php @@ -153,9 +153,10 @@ function formControls(array $form): array ->where('pageSettings.entries.label', 'Entries') ->where('sources.0.type', ElementSources::TYPE_HEADING) ->where('sources.0.page', 'Test Elements') - // ElementSources synthesizes a keyless blank heading as a - // separator; it's regenerated on every read and isn't saveable. - ->where('sources.0.form', null) + // The stored heading has no key of its own, so it's given one — + // without it the modal couldn't rename it or post it back. + ->where('sources.0.key', fn (string $key) => str_starts_with($key, 'heading:')) + ->where('sources.0.form.scope.0', 'sources') ->where('sources.1.page', 'Test Elements') ->where('sources.1.form.scope', ['sources', 'structured']) // Everything the modal used to build its fields client-side now @@ -468,6 +469,49 @@ function formControls(array $form): array ->assertJsonPath('sources.0.heading', ''); }); +it('keeps a keyless heading through a save', function () { + $projectConfig = app(ProjectConfig::class); + + // An element type's own group headings are defined without keys, as are + // headings stored before the modal started keying them. + $sources = postJson(action([ElementSourcesController::class, 'show']), [ + 'elementType' => TestElementSourcesElement::class, + ]) + ->assertOk() + ->json('sources'); + + $headingKey = $sources[0]['key']; + + expect($headingKey)->toStartWith('heading:'); + + postJson(action([ElementSourcesController::class, 'store']), [ + 'elementType' => TestElementSourcesElement::class, + 'sourceOrder' => array_column($sources, 'key'), + 'sourcePages' => array_column($sources, 'page', 'key'), + 'pageSettings' => ['Test Elements' => ['label' => 'Test Elements']], + 'sources' => [ + $headingKey => ['heading' => 'Primary Sources'], + ], + ])->assertOk(); + + expect($projectConfig->get(sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCES, TestElementSourcesElement::class))[0]) + ->toMatchArray([ + 'type' => ElementSources::TYPE_HEADING, + 'key' => $headingKey, + 'heading' => 'Primary Sources', + ]); + + // Now that it's stored, the key is stable across reads. (A real request + // would resolve ElementSources fresh; the test shares one scope.) + app()->forgetScopedInstances(); + + postJson(action([ElementSourcesController::class, 'show']), [ + 'elementType' => TestElementSourcesElement::class, + ]) + ->assertOk() + ->assertJsonPath('sources.0.key', $headingKey); +}); + it('stores single-page source settings without page reordering', function () { $projectConfig = app(ProjectConfig::class); diff --git a/tests/Feature/Http/Controllers/Entries/EntriesIndexControllerTest.php b/tests/Feature/Http/Controllers/Entries/EntriesIndexControllerTest.php index 3cac7f9271e..09165d813b7 100644 --- a/tests/Feature/Http/Controllers/Entries/EntriesIndexControllerTest.php +++ b/tests/Feature/Http/Controllers/Entries/EntriesIndexControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Section\Enums\SectionType; use CraftCms\Cms\Section\Models\Section; use CraftCms\Cms\Support\Facades\Sections; use CraftCms\Cms\User\Elements\User; @@ -37,9 +38,21 @@ ->assertRedirect(cp_url('content/entries')); }); -it('redirects the singles handle to the singles source page', function () { +it('redirects the legacy singles handle to the combined singles page', function () { actingAs(User::find()->one()); get(cp_url('entries/singles')) ->assertRedirect(cp_url('content/entries/singles')); }); + +it('redirects a single’s handle to its own content index page', function () { + actingAs(User::find()->one()); + + Section::factory()->create([ + 'type' => SectionType::Single, + 'handle' => 'homePage', + ]); + + get(cp_url('entries/homePage')) + ->assertRedirect(cp_url('content/entries/homePage')); +}); diff --git a/tests/Unit/Cp/NavigationTest.php b/tests/Unit/Cp/NavigationTest.php index 7d54c2d086c..45e632859df 100644 --- a/tests/Unit/Cp/NavigationTest.php +++ b/tests/Unit/Cp/NavigationTest.php @@ -5,6 +5,7 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Cp\Navigation; use CraftCms\Cms\Element\ElementSources; +use CraftCms\Cms\Entry\Entries; use CraftCms\Cms\Plugin\Plugins; use CraftCms\Cms\Support\Facades\Sections; use CraftCms\Cms\Support\Facades\Volumes; @@ -42,6 +43,7 @@ Mockery::mock(Utilities::class, ['getAuthorizedUtilityTypes' => new Collection]), Cms::config(), Mockery::mock(ElementSources::class), + Mockery::mock(Entries::class), ); $settingsItem = collect($navigation->getItems())->firstWhere('label', 'Settings'); @@ -58,6 +60,7 @@ Mockery::mock(Utilities::class, ['getAuthorizedUtilityTypes' => new Collection]), Cms::config(), Mockery::mock(ElementSources::class), + Mockery::mock(Entries::class), ); $graphqlItem = collect($navigation->getItems())->firstWhere('label', 'GraphQL'); diff --git a/workbench/config/app.php b/workbench/config/app.php index 1ced8bef0a1..f311c176c06 100644 --- a/workbench/config/app.php +++ b/workbench/config/app.php @@ -183,7 +183,11 @@ | */ - 'aliases' => Facade::defaultAliases()->merge([ + 'aliases' => Facade::defaultAliases()->merge( + // Craft's own facade aliases, which aren't registered via package discovery + // since craftcms/cms is the root package here rather than a dependency. + json_decode((string) file_get_contents(__DIR__.'/../../composer.json'), true)['extra']['laravel']['aliases'] ?? [], + )->merge([ // 'Example' => App\Facades\Example::class, ])->toArray(),