Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
46a10b5
Allows for grouping and reordering of collections within the Control …
JohnathonKoster Aug 8, 2026
4bcd700
Merge branch 'statamic:6.x' into groupable-listviews
JohnathonKoster Aug 8, 2026
393cd24
Code style
JohnathonKoster Aug 8, 2026
1508038
Merge branch 'groupable-listviews' of https://github.com/JohnathonKos…
JohnathonKoster Aug 8, 2026
665d534
Delete ResourceIndexTest.php
JohnathonKoster Aug 8, 2026
fd7aa1d
Prevent an "Other" group from appearing after clearing all groups.
JohnathonKoster Aug 8, 2026
7160dc4
Restore sorting across groups
JohnathonKoster Aug 8, 2026
fac2f98
Match the blueprint spacing
jaygeorge Aug 10, 2026
299d351
Remove min-height. I don't think it's needed and it makes new section…
jaygeorge Aug 10, 2026
2a2c434
Remove vertical chevron to make header sorting more consistent with t…
jaygeorge Aug 10, 2026
04184b6
Round "Add section" more so it better matches the sections above
jaygeorge Aug 11, 2026
28350e9
Tidy group names
jaygeorge Aug 11, 2026
7e281ad
Add Discard Changes button
jaygeorge Aug 11, 2026
b1449fe
Add a cancel button, and exit the page on save or discard
jaygeorge Aug 11, 2026
9ae303b
Escape should only close the combobox, it shouldn't also close the st…
jaygeorge Aug 11, 2026
8b334f1
Tweak
jaygeorge Aug 11, 2026
9158c00
Merge branch '6.x' into groupable-listviews
JohnathonKoster Aug 11, 2026
54c8817
Merge branch '6.x' into groupable-listviews
JohnathonKoster Aug 11, 2026
6d78b7c
Merge branch '6.x' into groupable-listviews
JohnathonKoster Aug 11, 2026
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
13 changes: 13 additions & 0 deletions config/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@

'pagination_size_options' => [10, 25, 50, 100],

/*
|--------------------------------------------------------------------------
| Resource Indexes
|--------------------------------------------------------------------------
|
| The repository used to store resource index group customizations.
|
*/

'resource_indexes' => [
'repository' => \Statamic\CP\ResourceIndex\PreferenceGroupRepository::class,
],

/*
|--------------------------------------------------------------------------
| Links to Documentation
Expand Down
2 changes: 1 addition & 1 deletion resources/css/components/blueprints.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
}

.blueprint-add-section-button {
@apply relative flex w-full items-center justify-center rounded-lg border border-dashed border-gray-500 text-gray-700 dark:border-gray-500 dark:text-gray-300;
@apply relative flex w-full items-center justify-center rounded-xl border border-dashed border-gray-500 text-gray-700 dark:border-gray-500 dark:text-gray-300;

&:hover {
@apply border-gray text-gray-925 dark:border-gray-400 dark:text-gray-200;
Expand Down
60 changes: 50 additions & 10 deletions resources/js/components/collections/Listing.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
<template>
<ui-header :title="__('Collections')" icon="collections">
<ui-toggle-group v-model="mode">
<ui-toggle-item icon="layout-list" value="list" :aria-label="__('List view')" />
<ui-toggle-item icon="layout-grid" value="grid" :aria-label="__('Grid view')" />
</ui-toggle-group>
<ui-button-group>
<ui-toggle-group v-model="mode">
<ui-toggle-item icon="layout-list" value="list" :aria-label="__('List view')" />
<ui-toggle-item icon="layout-grid" value="grid" :aria-label="__('Grid view')" />
</ui-toggle-group>
<ui-button
v-if="resourceIndex.organizeUrl"
:text="__('Organize')"
:href="resourceIndex.organizeUrl"
icon="hierarchy"
/>
</ui-button-group>
<ui-button
:href="createUrl"
:text="__('Create Collection')"
variant="primary"
v-if="canCreateCollections"
/>
</ui-header>
<div class="@container/collections flex flex-wrap py-2 gap-y-6 -mx-3" v-if="mode === 'grid'">
<div v-for="collection in items" class="w-full @4xl:w-1/2 px-3" :key="collection.id">
<div
v-if="mode === 'grid'"
v-for="(group, groupIndex) in collectionGroups"
:key="group.id"
class="@container/collections flex flex-wrap py-2 gap-y-6 -mx-3"
:class="{ 'mt-6': groupIndex > 0 }"
>
<ui-subheading
v-if="group.title"
size="lg"
class="w-full px-3 -mt-2 -mb-2"
:text="group.title"
/>
<div v-for="collection in group.items" class="w-full @4xl:w-1/2 px-3" :key="collection.id">
<ui-panel>
<ui-panel-header class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
Expand Down Expand Up @@ -138,13 +158,12 @@
</div>
</div>

<ui-listing
<ResourceIndexListing
v-if="mode === 'list'"
:resource-index="resourceIndex"
:items="items"
:columns="columns"
:action-url="actionUrl"
:allow-search="false"
:allow-customizing-columns="false"
@refreshing="request"
>
<template #cell-title="{ row: collection }">
Expand Down Expand Up @@ -184,7 +203,7 @@
<DropdownItem v-if="collection.blueprint_editable" :text="__('Edit Blueprints')" icon="blueprint-edit" :href="collection.blueprints_url" />
<DropdownItem v-if="collection.editable" :text="__('Scaffold Views')" icon="scaffold" :href="collection.scaffold_url" />
</template>
</ui-listing>
</ResourceIndexListing>
</template>

<script>
Expand All @@ -199,6 +218,8 @@ import {
DropdownSeparator,
} from '@/components/ui';
import ItemActions from '@/components/actions/ItemActions.vue';
import ResourceIndexListing from '@/components/resource-indexes/Listing.vue';
import { groupResourceIndexItems } from '@/components/resource-indexes/group-items.js';
import { Link } from '@inertiajs/vue3';

export default {
Expand All @@ -213,6 +234,7 @@ export default {
DropdownItem,
DropdownSeparator,
ItemActions,
ResourceIndexListing,
},

props: {
Expand All @@ -221,6 +243,10 @@ export default {
initialRows: Array,
initialColumns: Array,
actionUrl: String,
resourceIndex: {
type: Object,
required: true,
},
},

data() {
Expand All @@ -236,6 +262,12 @@ export default {
};
},

computed: {
collectionGroups() {
return groupResourceIndexItems(this.items, this.resourceIndex);
},
},

watch: {
mode(mode) {
this.$preferences.set('collections.listing_mode', mode);
Expand Down Expand Up @@ -298,6 +330,14 @@ export default {
url: this.createUrl,
});

Statamic.$commandPalette.add({
category: Statamic.$commandPalette.category.Actions,
text: __('Organize Collections'),
icon: 'hierarchy',
when: () => !!this.resourceIndex.organizeUrl,
url: this.resourceIndex.organizeUrl,
});

Statamic.$commandPalette.add({
category: Statamic.$commandPalette.category.Actions,
text: __('Switch to Grid Layout'),
Expand Down
81 changes: 81 additions & 0 deletions resources/js/components/resource-indexes/Listing.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script setup>
import { computed, ref, useSlots } from 'vue';
import { Listing, ListingTable, Panel, Subheading } from '@ui';
import { groupResourceIndexItems } from './group-items.js';

const emit = defineEmits(['refreshing']);

const props = defineProps({
resourceIndex: {
type: Object,
required: true,
},
items: {
type: Array,
required: true,
},
columns: {
type: Array,
required: true,
},
actionUrl: {
type: String,
default: null,
},
});
const slots = useSlots();
const grouped = computed(() => props.resourceIndex.hasSavedGroups || props.resourceIndex.groups.length > 0);
const sortColumn = ref(props.resourceIndex.hasSavedGroups ? null : undefined);
const forwardedTableCellSlots = computed(() => Object.keys(slots)
.filter((slotName) => slotName.startsWith('cell-'))
.reduce((forwardedSlots, slotName) => {
forwardedSlots[slotName] = slots[slotName];
return forwardedSlots;
}, {}),
);

function groupsFor(items) {
return groupResourceIndexItems(items, props.resourceIndex, sortColumn.value === null);
}
</script>

<template>
<Listing
:items="items"
:columns="columns"
:action-url="actionUrl"
:allow-search="false"
:allow-customizing-columns="false"
v-model:sort-column="sortColumn"
@refreshing="emit('refreshing')"
>
<template #default="{ items }">
<div
v-if="!items.length"
class="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 p-6 text-center text-gray-500"
v-text="__('No results')"
/>

<div v-else class="space-y-6">
<section v-for="group in groupsFor(items)" :key="group.id">
<Subheading v-if="group.title" class="mb-2 ps-0.5" :text="group.title" />

<Panel class="relative overflow-x-auto" style="container-type: scroll-state;">
<ListingTable :items="grouped ? group.items : undefined">
<template
v-for="(slot, slotName) in forwardedTableCellSlots"
:key="slotName"
#[slotName]="slotProps"
>
<component :is="slot" v-bind="slotProps" />
</template>
<template v-if="$slots['prepended-row-actions']" #prepended-row-actions="{ row }">
<slot name="prepended-row-actions" :row="row" />
</template>
</ListingTable>
</Panel>
</section>
</div>
</template>
</Listing>
</template>
30 changes: 30 additions & 0 deletions resources/js/components/resource-indexes/group-items.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export function groupResourceIndexItems(items, resourceIndex, preserveSavedOrder = true) {
const { groups, fallbackGroup, hasSavedGroups } = resourceIndex;
const grouped = hasSavedGroups || groups.length > 0;

if (!grouped) {
return items.length ? [{ id: '__all', title: null, items }] : [];
}

const itemsById = new Map(items.map((item) => [String(item.id), item]));
const configuredItemIds = new Set(groups.flatMap((group) => group.items.map(String)));

const configuredGroups = groups.map((group) => {
const groupItemIds = new Set(group.items.map(String));

return {
id: group.id,
title: group.title,
items: hasSavedGroups && preserveSavedOrder
? group.items.map((id) => itemsById.get(String(id))).filter(Boolean)
: items.filter((item) => groupItemIds.has(String(item.id))),
};
});

const fallbackItems = items.filter((item) => !configuredItemIds.has(String(item.id)));

return [
...configuredGroups,
{ ...fallbackGroup, items: fallbackItems },
].filter((group) => group.items.length);
}
68 changes: 68 additions & 0 deletions resources/js/components/resource-indexes/organize-groups.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
export function unassignedResourceIndexItems(items, groups) {
const assignedIds = new Set(groups.flatMap((group) => group.items.map(String)));

return items.filter((item) => !assignedIds.has(String(item.id)));
}

export function isResourceIndexFallbackReorder(currentGroupId, overGroupId, fallbackGroupId) {
return currentGroupId === fallbackGroupId && overGroupId === fallbackGroupId;
}

export function addResourceIndexItems(groups, groupId, itemIds) {
const group = groups.find((group) => group.id === groupId);
if (!group) return;

const existingIds = new Set(group.items.map(String));

itemIds.map(String).forEach((id) => {
if (existingIds.has(id)) return;

existingIds.add(id);
group.items.push(id);
});
}

export function removeResourceIndexItem(groups, groupId, itemId) {
const group = groups.find((group) => group.id === groupId);
if (!group) return;

group.items = group.items.filter((id) => String(id) !== String(itemId));
}

export function moveResourceIndexItem(groups, {
itemId,
oldGroupId,
oldIndex,
newGroupId,
newIndex,
fallbackGroupId,
}) {
itemId = String(itemId);

if (isResourceIndexFallbackReorder(oldGroupId, newGroupId, fallbackGroupId)) return;

if (newGroupId === fallbackGroupId) {
groups.forEach((group) => {
group.items = group.items.filter((id) => String(id) !== itemId);
});

return;
}

const newGroup = groups.find((group) => group.id === newGroupId);
if (!newGroup) return;

if (oldGroupId === newGroupId) {
newGroup.items.splice(newIndex, 0, newGroup.items.splice(oldIndex, 1)[0]);

return;
}

if (oldGroupId !== fallbackGroupId) {
removeResourceIndexItem(groups, oldGroupId, itemId);
}

if (!newGroup.items.some((id) => String(id) === itemId)) {
newGroup.items.splice(newIndex, 0, itemId);
}
}
9 changes: 8 additions & 1 deletion resources/js/components/ui/Combobox/Combobox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,13 @@ function openDropdown(e) {
updateDropdownOpen(true);
}

function handleEscapeKeyDown(event) {
event.preventDefault();
event.stopPropagation();
updateDropdownOpen(false);
focus();
}

function onBlur(e) {
if (!props.taggable) return;

Expand Down Expand Up @@ -458,7 +465,7 @@ defineExpose({
adaptiveWidth && 'w-max max-w-md',
]"
data-ui-combobox-content
@escape-key-down="focus"
@escape-key-down="handleEscapeKeyDown"
>
<FocusScope
:trapped="!searchable"
Expand Down
6 changes: 3 additions & 3 deletions resources/js/components/ui/Listing/Listing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ let popping = false;
let source = null;
const searchQuery = ref(null);
const columns = ref(initializeColumns());
const sortColumn = ref(props.sortColumn || (columns.value.length ? columns.value[0].field : null));
const sortColumn = ref(props.sortColumn === null ? null : (props.sortColumn || (columns.value.length ? columns.value[0].field : null)));
const sortDirection = ref(props.sortDirection || getDefaultSortDirectionForColumn(sortColumn.value));
const selections = ref(props.selections || []);
const allowsSelections = computed(() => (props.selections || (props.allowBulkActions && hasActions.value)) && !props.reorderable);
Expand Down Expand Up @@ -206,15 +206,15 @@ const items = computed({
.map((result) => result.obj);
}

if (props.sortable) {
if (props.sortable && sortColumn.value) {
items = sortBy(items, (obj) => {
let value = obj[sortColumn.value];
if (typeof value === 'string') value = value.toLowerCase();
return value;
});
}

return sortDirection.value === 'desc' ? items.reverse() : items;
return sortColumn.value && sortDirection.value === 'desc' ? items.reverse() : items;
},
set(newItems) {
rawItems.value = newItems;
Expand Down
Loading
Loading