diff --git a/config/sections/files.php b/config/sections/files.php
index 169a3edc5f..e2d48a8034 100644
--- a/config/sections/files.php
+++ b/config/sections/files.php
@@ -6,6 +6,7 @@
return [
'mixins' => [
+ 'batch',
'details',
'empty',
'headline',
@@ -90,14 +91,15 @@
$files = $files->flip();
}
+ return $files;
+ },
+ 'modelsPaginated' => function () {
// apply the default pagination
- $files = $files->paginate([
+ return $this->models()->paginate([
'page' => $this->page,
'limit' => $this->limit,
'method' => 'none' // the page is manually provided
]);
-
- return $files;
},
'files' => function () {
return $this->models;
@@ -109,7 +111,7 @@
// a different parent model
$dragTextAbsolute = $this->model->is($this->parent) === false;
- foreach ($this->models as $file) {
+ foreach ($this->modelsPaginated() as $file) {
$panel = $file->panel();
$permissions = $file->permissions();
@@ -144,7 +146,7 @@
return $data;
},
'total' => function () {
- return $this->models->pagination()->total();
+ return $this->models()->count();
},
'errors' => function () {
$errors = [];
@@ -218,6 +220,15 @@
return true;
}
+ ],
+ [
+ 'pattern' => 'delete',
+ 'method' => 'DELETE',
+ 'action' => function () {
+ return $this->section()->deleteSelected(
+ ids: $this->requestBody('ids'),
+ );
+ }
]
];
},
@@ -229,6 +240,7 @@
'options' => [
'accept' => $this->accept,
'apiUrl' => $this->parent->apiUrl(true) . '/sections/' . $this->name,
+ 'batch' => $this->batch,
'columns' => $this->columnsWithTypes(),
'empty' => $this->empty,
'headline' => $this->headline,
diff --git a/config/sections/mixins/batch.php b/config/sections/mixins/batch.php
new file mode 100644
index 0000000000..25128f253a
--- /dev/null
+++ b/config/sections/mixins/batch.php
@@ -0,0 +1,45 @@
+ [
+ /**
+ * Activates the batch delete option for the section
+ */
+ 'batch' => function (bool $batch = false) {
+ return $batch;
+ },
+ ],
+ 'methods' => [
+ 'deleteSelected' => function (array $ids): bool {
+ if ($ids === []) {
+ return true;
+ }
+
+ // check if batch deletion is allowed
+ if ($this->batch() === false) {
+ throw new PermissionException(
+ message: 'The section does not support batch actions'
+ );
+ }
+
+ $min = $this->min();
+
+ // check if the section has enough items after the deletion
+ if ($this->total() - count($ids) < $min) {
+ throw new Exception(
+ message: I18n::template('error.section.' . $this->type() . '.min.' . I18n::form($min), [
+ 'min' => $min,
+ 'section' => $this->headline()
+ ])
+ );
+ }
+
+ $this->models()->delete($ids);
+ return true;
+ }
+ ]
+];
diff --git a/config/sections/pages.php b/config/sections/pages.php
index 6f296c15c3..bdbd61346a 100644
--- a/config/sections/pages.php
+++ b/config/sections/pages.php
@@ -10,6 +10,7 @@
return [
'mixins' => [
+ 'batch',
'details',
'empty',
'headline',
@@ -149,25 +150,26 @@
$pages = $pages->flip();
}
+ return $pages;
+ },
+ 'modelsPaginated' => function () {
// pagination
- $pages = $pages->paginate([
+ return $this->models()->paginate([
'page' => $this->page,
'limit' => $this->limit,
'method' => 'none' // the page is manually provided
]);
-
- return $pages;
},
'pages' => function () {
return $this->models;
},
'total' => function () {
- return $this->models->pagination()->total();
+ return $this->models()->count();
},
'data' => function () {
$data = [];
- foreach ($this->models as $page) {
+ foreach ($this->modelsPaginated() as $page) {
$panel = $page->panel();
$permissions = $page->permissions();
@@ -315,12 +317,28 @@
return $blueprints;
},
],
+ // @codeCoverageIgnoreStart
+ 'api' => function () {
+ return [
+ [
+ 'pattern' => 'delete',
+ 'method' => 'DELETE',
+ 'action' => function () {
+ return $this->section()->deleteSelected(
+ ids: $this->requestBody('ids'),
+ );
+ }
+ ]
+ ];
+ },
+ // @codeCoverageIgnoreEnd
'toArray' => function () {
return [
'data' => $this->data,
'errors' => $this->errors,
'options' => [
'add' => $this->add,
+ 'batch' => $this->batch,
'columns' => $this->columnsWithTypes(),
'empty' => $this->empty,
'headline' => $this->headline,
diff --git a/i18n/translations/en.json b/i18n/translations/en.json
index 70bece0b76..0ec1dbea57 100644
--- a/i18n/translations/en.json
+++ b/i18n/translations/en.json
@@ -108,6 +108,7 @@
"error.file.changeTemplate.invalid": "The template for the file \"{id}\" cannot be changed to \"{template}\" (valid: \"{blueprints}\")",
"error.file.changeTemplate.permission": "You are not allowed to change the template for the file \"{id}\"",
+ "error.file.delete.multiple": "Not all files could be deleted",
"error.file.duplicate": "A file with the name \"{filename}\" already exists",
"error.file.extension.forbidden": "The extension \"{extension}\" is not allowed",
"error.file.extension.invalid": "Invalid extension: {extension}",
@@ -170,6 +171,7 @@
"error.page.delete": "The page \"{slug}\" cannot be deleted",
"error.page.delete.confirm": "Please enter the page title to confirm",
"error.page.delete.hasChildren": "The page has subpages and cannot be deleted",
+ "error.page.delete.multiple": "Not all pages could be deleted",
"error.page.delete.permission": "You are not allowed to delete \"{slug}\"",
"error.page.draft.duplicate": "A page draft with the URL appendix \"{slug}\" already exists",
"error.page.duplicate": "A page with the URL appendix \"{slug}\" already exists",
@@ -381,6 +383,7 @@
"file.sort": "Change position",
"files": "Files",
+ "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.",
"files.empty": "No files yet",
"filter": "Filter",
@@ -590,6 +593,7 @@
"page.status.unlisted.description": "The page is only accessible via URL",
"pages": "Pages",
+ "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.",
"pages.empty": "No pages yet",
"pages.status.draft": "Drafts",
"pages.status.listed": "Published",
diff --git a/panel/lab/components/item/1_list/index.vue b/panel/lab/components/item/1_list/index.vue
index fc62ac1528..9bedcc3b86 100644
--- a/panel/lab/components/item/1_list/index.vue
+++ b/panel/lab/components/item/1_list/index.vue
@@ -69,6 +69,14 @@
width="1/2"
/>
+
+
+
+
+
+
+
+
+
+
+
+
+ Selected: {{ selected.join(", ") }}
+
@@ -10,6 +15,30 @@
export default {
props: {
items: Array
+ },
+ data() {
+ return {
+ selected: []
+ };
+ },
+ computed: {
+ selectableItems() {
+ return this.items.map((item) => {
+ return {
+ ...item,
+ selectable: true
+ };
+ });
+ }
+ },
+ methods: {
+ onSelect(item, index) {
+ if (this.selected.includes(index)) {
+ this.selected = this.selected.filter((i) => i !== index);
+ } else {
+ this.selected.push(index);
+ }
+ }
}
};
diff --git a/panel/lab/components/items/2_cards/index.vue b/panel/lab/components/items/2_cards/index.vue
index e9ae98d1d1..02d8735100 100644
--- a/panel/lab/components/items/2_cards/index.vue
+++ b/panel/lab/components/items/2_cards/index.vue
@@ -3,6 +3,16 @@
+
+
+
+ Selected: {{ selected.join(", ") }}
+
@@ -10,6 +20,30 @@
export default {
props: {
items: Array
+ },
+ data() {
+ return {
+ selected: []
+ };
+ },
+ computed: {
+ selectableItems() {
+ return this.items.map((item) => {
+ return {
+ ...item,
+ selectable: true
+ };
+ });
+ }
+ },
+ methods: {
+ onSelect(item, index) {
+ if (this.selected.includes(index)) {
+ this.selected = this.selected.filter((i) => i !== index);
+ } else {
+ this.selected.push(index);
+ }
+ }
}
};
diff --git a/panel/lab/components/items/3_cardlets/index.vue b/panel/lab/components/items/3_cardlets/index.vue
index aead0089f0..ca6f85b0b9 100644
--- a/panel/lab/components/items/3_cardlets/index.vue
+++ b/panel/lab/components/items/3_cardlets/index.vue
@@ -3,6 +3,16 @@
+
+
+
+ Selected: {{ selected.join(", ") }}
+
@@ -10,6 +20,30 @@
export default {
props: {
items: Array
+ },
+ data() {
+ return {
+ selected: []
+ };
+ },
+ computed: {
+ selectableItems() {
+ return this.items.map((item) => {
+ return {
+ ...item,
+ selectable: true
+ };
+ });
+ }
+ },
+ methods: {
+ onSelect(item, index) {
+ if (this.selected.includes(index)) {
+ this.selected = this.selected.filter((i) => i !== index);
+ } else {
+ this.selected.push(index);
+ }
+ }
}
};
diff --git a/panel/lab/components/items/4_table/index.vue b/panel/lab/components/items/4_table/index.vue
index a760189118..ecc6464580 100644
--- a/panel/lab/components/items/4_table/index.vue
+++ b/panel/lab/components/items/4_table/index.vue
@@ -2,26 +2,23 @@
+
+
+
+ Selected: {{ selected.join(", ") }}
+
@@ -29,6 +26,47 @@
export default {
props: {
items: Array
+ },
+ data() {
+ return {
+ selected: []
+ };
+ },
+ computed: {
+ columns() {
+ return {
+ image: {
+ label: "",
+ type: "image",
+ width: "var(--table-row-height)"
+ },
+ text: {
+ label: "Text",
+ type: "text"
+ },
+ info: {
+ label: "Info",
+ type: "text"
+ }
+ };
+ },
+ selectableItems() {
+ return this.items.map((item) => {
+ return {
+ ...item,
+ selectable: true
+ };
+ });
+ }
+ },
+ methods: {
+ onSelect(item, index) {
+ if (this.selected.includes(index)) {
+ this.selected = this.selected.filter((i) => i !== index);
+ } else {
+ this.selected.push(index);
+ }
+ }
}
};
diff --git a/panel/lab/components/items/index.php b/panel/lab/components/items/index.php
index 75b3ab7e5b..3ff50bece9 100644
--- a/panel/lab/components/items/index.php
+++ b/panel/lab/components/items/index.php
@@ -5,7 +5,7 @@
return [
'docs' => 'k-items',
- 'items' => A::map(range(0, 20), function ($item) {
+ 'items' => A::map(range(0, 10), function ($item) {
return [
'text' => 'This is item ' . $item,
'info' => 'Some info text',
diff --git a/panel/lab/components/tables/1_horizontal/index.vue b/panel/lab/components/tables/1_horizontal/index.vue
index 2007c8bed9..0a3589445e 100644
--- a/panel/lab/components/tables/1_horizontal/index.vue
+++ b/panel/lab/components/tables/1_horizontal/index.vue
@@ -129,10 +129,10 @@
}"
element="tbody"
>
-
+
{{ i }}
-
+
|
Kirby |
mail@getkirby.com |
diff --git a/panel/lab/components/tables/4_component/index.vue b/panel/lab/components/tables/4_component/index.vue
new file mode 100644
index 0000000000..73f24c2706
--- /dev/null
+++ b/panel/lab/components/tables/4_component/index.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/panel/src/components/Collection/Collection.vue b/panel/src/components/Collection/Collection.vue
index 3042b57f67..0851a2e756 100644
--- a/panel/src/components/Collection/Collection.vue
+++ b/panel/src/components/Collection/Collection.vue
@@ -15,6 +15,7 @@
items,
layout,
link,
+ selectable,
size,
sortable,
theme
@@ -22,6 +23,7 @@
@change="$emit('change', $event)"
@item="$emit('item', $event)"
@option="onOption"
+ @select="onSelect"
@sort="$emit('sort', $event)"
>
@@ -126,6 +128,9 @@ export default {
onOption(...args) {
this.$emit("action", ...args);
this.$emit("option", ...args);
+ },
+ onSelect(...args) {
+ this.$emit("select", ...args);
}
}
};
diff --git a/panel/src/components/Collection/Item.vue b/panel/src/components/Collection/Item.vue
index 73b27faef6..208a98b826 100644
--- a/panel/src/components/Collection/Item.vue
+++ b/panel/src/components/Collection/Item.vue
@@ -4,9 +4,10 @@
:class="['k-item', `k-${layout}-item`, $attrs.class]"
:data-has-image="hasFigure"
:data-layout="layout"
+ :data-selectable="selectable"
:data-theme="theme"
:style="$attrs.style"
- @click="$emit('click', $event)"
+ @click="onClick"
@dragstart="$emit('drag', $event)"
>
@@ -25,7 +26,11 @@
-
+
@@ -37,7 +42,7 @@
@@ -48,8 +53,16 @@
v-bind="button"
/>
+
+
-
+
diff --git a/panel/src/components/Collection/Items.vue b/panel/src/components/Collection/Items.vue
index 9c138cc400..a8a37cb587 100644
--- a/panel/src/components/Collection/Items.vue
+++ b/panel/src/components/Collection/Items.vue
@@ -6,6 +6,7 @@
:class="$attrs.class"
:style="$attrs.style"
@change="$emit('change', $event)"
+ @select="onSelect"
@sort="$emit('sort', $event)"
@option="onOption"
>
@@ -39,6 +40,7 @@
:image="imageOptions(item)"
:layout="layout"
:link="link ? item.link : false"
+ :selectable="selectable && item.selectable"
:sortable="sortable && item.sortable"
:theme="theme"
:width="item.column"
@@ -46,6 +48,7 @@
@drag="onDragStart($event, item.dragText)"
@mouseover.native="$emit('hover', $event, item, itemIndex)"
@option="onOption($event, item, itemIndex)"
+ @select="onSelect(item, itemIndex)"
>
@@ -94,6 +97,11 @@ export const props = {
type: Boolean,
default: true
},
+ /**
+ * Whether items are generally selectable.
+ * Each item can disable this individually.
+ */
+ selectable: Boolean,
/**
* Whether items are generally sortable.
* Each item can disable this individually.
@@ -140,6 +148,7 @@ export default {
columns: this.columns,
fields: this.fields,
rows: this.items,
+ selectable: this.selectable,
sortable: this.sortable
};
}
@@ -151,6 +160,9 @@ export default {
onOption(option, item, itemIndex) {
this.$emit("option", option, item, itemIndex);
},
+ onSelect(event, item, itemIndex) {
+ this.$emit("select", event, item, itemIndex);
+ },
imageOptions(item) {
let globalOptions = this.image;
let localOptions = item.image;
diff --git a/panel/src/components/Layout/Table.vue b/panel/src/components/Layout/Table.vue
index 2ac44410c8..46d569bc88 100644
--- a/panel/src/components/Layout/Table.vue
+++ b/panel/src/components/Layout/Table.vue
@@ -72,8 +72,10 @@
v-for="(row, rowIndex) in values"
:key="row.id ?? row._id ?? row.value ?? JSON.stringify(row)"
:class="{
- 'k-table-sortable-row': sortable && row.sortable !== false
+ 'k-table-sortable-row': rowIsSortable(row)
}"
+ :data-selectable="rowIsSelectable(row)"
+ :data-sortable="rowIsSortable(row)"
>
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
|
@@ -208,6 +220,10 @@ export default {
* Optional pagination settings
*/
pagination: [Object, Boolean],
+ /**
+ * Whether table is sortable
+ */
+ selectable: Boolean,
/**
* Whether table is sortable
*/
@@ -261,7 +277,7 @@ export default {
* @returns {bool}
*/
hasIndexColumn() {
- return this.sortable || this.index !== false;
+ return this.sortable || this.selectable || this.index !== false;
},
/**
* Whether to show options column
@@ -348,6 +364,16 @@ export default {
this.$emit("input", this.values);
this.$emit("sort", this.values);
},
+ rowIsSelectable(row) {
+ return this.selectable === true && row.selectable !== false;
+ },
+ rowIsSortable(row) {
+ return (
+ this.sortable === true &&
+ this.selectable === false &&
+ row.sortable !== false
+ );
+ },
/**
* Returns width styling based on column fraction
* @param {string} fraction
@@ -371,7 +397,7 @@ export default {
:root {
--table-cell-padding: var(--spacing-3);
--table-color-back: light-dark(var(--color-white), var(--color-gray-850));
- --table-color-border: var(--panel-color-back);
+ --table-color-border: light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.375));
--table-color-hover: light-dark(var(--color-gray-100), rgba(0, 0, 0, 0.1));
--table-color-th-back: light-dark(
var(--color-gray-100),
@@ -457,6 +483,9 @@ export default {
}
/* Table Body */
+.k-table tbody tr td {
+ background: var(--table-color-back);
+}
.k-table tbody tr:hover td {
background: var(--table-color-hover);
}
@@ -498,19 +527,33 @@ export default {
color: var(--color-text-dimmed);
line-height: 1.1em;
}
+.k-table .k-table-index-column:has(.k-table-index-checkbox) {
+ padding: 0;
+}
+.k-table .k-table-index-checkbox {
+ height: 100%;
+ display: grid;
+ place-items: center;
+}
/* Table Index with sort handle */
-.k-table .k-table-index-column .k-sort-handle {
+.k-table tr[data-sortable="true"] .k-table-index-column .k-sort-handle {
--button-width: 100%;
display: none;
}
-.k-table tr.k-table-sortable-row:hover .k-table-index-column .k-table-index {
+.k-table tr[data-sortable="true"]:hover .k-table-index-column .k-table-index {
display: none;
}
-.k-table tr.k-table-sortable-row:hover .k-table-index-column .k-sort-handle {
+.k-table tr[data-sortable="true"]:hover .k-table-index-column .k-sort-handle {
display: flex;
}
+/* Selectable rows */
+.k-table tr[data-selectable="true"]:has(.k-table-index-column input:checked) {
+ --table-color-back: light-dark(var(--color-blue-250), var(--color-blue-800));
+ --table-color-hover: var(--table-color-back);
+}
+
/* Table Options */
.k-table .k-table-options-column {
padding: 0;
diff --git a/panel/src/components/Sections/FilesSection.vue b/panel/src/components/Sections/FilesSection.vue
index ef74975a3c..cd782c74cb 100644
--- a/panel/src/components/Sections/FilesSection.vue
+++ b/panel/src/components/Sections/FilesSection.vue
@@ -35,7 +35,9 @@ export default {
delete: this.data.length > this.options.min
}
}),
- sortable: file.permissions.sort && this.options.sortable
+ selectable: this.isSelecting,
+ sortable:
+ file.permissions.sort && this.options.sortable && !this.isSelecting
}));
},
type() {
diff --git a/panel/src/components/Sections/ModelsSection.vue b/panel/src/components/Sections/ModelsSection.vue
index 52fe45f8a4..66778eaaa6 100644
--- a/panel/src/components/Sections/ModelsSection.vue
+++ b/panel/src/components/Sections/ModelsSection.vue
@@ -39,6 +39,7 @@
v-on="canAdd ? { empty: onAdd } : {}"
@action="onAction"
@change="onChange"
+ @select="onSelect"
@sort="onSort"
@paginate="onPaginate"
/>
@@ -65,7 +66,9 @@ export default {
error: null,
isLoading: false,
isProcessing: false,
+ isSelecting: false,
options: {
+ batch: false,
columns: {},
empty: null,
headline: null,
@@ -81,7 +84,8 @@ export default {
page: null
},
searchterm: null,
- searching: false
+ searching: false,
+ selected: []
};
},
computed: {
@@ -91,6 +95,39 @@ export default {
buttons() {
let buttons = [];
+ if (this.isSelecting) {
+ buttons.push({
+ disabled: this.selected.length === 0,
+ icon: "trash",
+ text: this.$t("delete") + ` (${this.selected.length})`,
+ theme: "negative",
+ click: () => {
+ this.$panel.dialog.open({
+ component: "k-remove-dialog",
+ props: {
+ text: this.confirmDeleteSelectedMessage
+ },
+ on: {
+ submit: () => {
+ this.$panel.dialog.close();
+ this.deleteSelected();
+ }
+ }
+ });
+ },
+ responsive: true
+ });
+
+ buttons.push({
+ icon: "cancel",
+ text: this.$t("cancel"),
+ click: this.onSelectToggle,
+ responsive: true
+ });
+
+ return buttons;
+ }
+
if (this.canSearch) {
buttons.push({
icon: "filter",
@@ -100,6 +137,15 @@ export default {
});
}
+ if (this.canSelect) {
+ buttons.push({
+ icon: "checklist",
+ click: this.onSelectToggle,
+ title: this.$t("select"),
+ responsive: true
+ });
+ }
+
if (this.canAdd) {
buttons.push({
icon: this.addIcon,
@@ -120,6 +166,9 @@ export default {
canSearch() {
return this.options.search;
},
+ canSelect() {
+ return this.options.batch && this.items.length > 0;
+ },
collection() {
return {
columns: this.options.columns,
@@ -129,10 +178,16 @@ export default {
help: this.options.help,
items: this.items,
pagination: this.pagination,
+ selectable: !this.isProcessing && this.isSelecting,
sortable: !this.isProcessing && this.options.sortable,
size: this.options.size
};
},
+ confirmDeleteSelectedMessage() {
+ return this.$t(`${this.type}.delete.confirm.selected`, {
+ count: this.selected.length
+ });
+ },
emptyProps() {
return {
icon: "page",
@@ -185,11 +240,40 @@ export default {
this.reload();
}
},
+ created() {
+ this.$events.on("selecting", this.stopSelectingCollision);
+ },
+ destroyed() {
+ this.$events.off("selecting", this.stopSelectingCollision);
+ },
mounted() {
this.search = debounce(this.search, 200);
this.load();
},
methods: {
+ async deleteSelected() {
+ if (this.selected.length === 0) {
+ return;
+ }
+
+ this.isProcessing = true;
+
+ try {
+ await this.$api.delete(
+ this.parent + "/sections/" + this.name + "/delete",
+ {
+ ids: this.selected.map((item) => item.id)
+ }
+ );
+ } catch (error) {
+ this.$panel.notification.error(error);
+ } finally {
+ this.reload();
+ this.isSelecting = false;
+ this.isProcessing = false;
+ this.selected = [];
+ }
+ },
async load(reload) {
this.isProcessing = true;
@@ -221,7 +305,6 @@ export default {
onAdd() {},
onChange() {},
onDrop() {},
- onSort() {},
onPaginate(pagination) {
localStorage.setItem(this.paginationId, pagination.page);
this.pagination = pagination;
@@ -231,6 +314,33 @@ export default {
this.searching = !this.searching;
this.searchterm = null;
},
+ onSelect(item) {
+ if (this.selected.includes(item)) {
+ this.selected = this.selected.filter(
+ (selected) => selected.id !== item.id
+ );
+ } else {
+ this.selected.push(item);
+ }
+ },
+ onSelectToggle() {
+ this.isSelecting ? this.stopSelecting() : this.startSelecting();
+ },
+ onSort() {},
+ startSelecting() {
+ this.isSelecting = true;
+ this.selected = [];
+ this.$events.emit("selecting", this.name);
+ },
+ stopSelecting() {
+ this.isSelecting = false;
+ this.selected = [];
+ },
+ stopSelectingCollision(name) {
+ if (name !== this.name) {
+ this.stopSelecting();
+ }
+ },
async reload() {
await this.load(true);
},
diff --git a/panel/src/components/Sections/PagesSection.vue b/panel/src/components/Sections/PagesSection.vue
index 2dcf77b91e..ca45543fb0 100644
--- a/panel/src/components/Sections/PagesSection.vue
+++ b/panel/src/components/Sections/PagesSection.vue
@@ -9,7 +9,8 @@ export default {
},
items() {
return this.data.map((page) => {
- const sortable = page.permissions.sort && this.options.sortable;
+ const sortable =
+ page.permissions.sort && this.options.sortable && !this.isSelecting;
const deletable = this.data.length > this.options.min;
return {
@@ -38,6 +39,7 @@ export default {
sort: sortable
}
}),
+ selectable: this.isSelecting,
sortable
};
});
diff --git a/panel/src/styles/config/colors.css b/panel/src/styles/config/colors.css
index 9776e04fde..d6e1586856 100644
--- a/panel/src/styles/config/colors.css
+++ b/panel/src/styles/config/colors.css
@@ -150,6 +150,7 @@
--color-blue-l-100: calc(var(--color-l-100) + var(--color-blue-boost));
--color-blue-l-200: calc(var(--color-l-200) + var(--color-blue-boost));
+ --color-blue-l-250: calc(var(--color-l-250) + var(--color-blue-boost));
--color-blue-l-300: calc(var(--color-l-300) + var(--color-blue-boost));
--color-blue-l-400: calc(var(--color-l-400) + var(--color-blue-boost));
--color-blue-l-500: calc(var(--color-l-500) + var(--color-blue-boost));
@@ -160,6 +161,7 @@
--color-blue-100: hsl(var(--color-blue-hs), var(--color-blue-l-100));
--color-blue-200: hsl(var(--color-blue-hs), var(--color-blue-l-200));
+ --color-blue-250: hsl(var(--color-blue-hs), var(--color-blue-l-250));
--color-blue-300: hsl(var(--color-blue-hs), var(--color-blue-l-300));
--color-blue-400: hsl(var(--color-blue-hs), var(--color-blue-l-400));
--color-blue-500: hsl(var(--color-blue-hs), var(--color-blue-l-500));
diff --git a/src/Cms/Core.php b/src/Cms/Core.php
index 10847d4db4..6498a38436 100644
--- a/src/Cms/Core.php
+++ b/src/Cms/Core.php
@@ -406,6 +406,7 @@ public function snippets(): array
public function sectionMixins(): array
{
return [
+ 'batch' => $this->root . '/sections/mixins/batch.php',
'details' => $this->root . '/sections/mixins/details.php',
'empty' => $this->root . '/sections/mixins/empty.php',
'headline' => $this->root . '/sections/mixins/headline.php',
diff --git a/src/Cms/Files.php b/src/Cms/Files.php
index f935bf9303..a0abf4c45f 100644
--- a/src/Cms/Files.php
+++ b/src/Cms/Files.php
@@ -2,9 +2,12 @@
namespace Kirby\Cms;
+use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
+use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
use Kirby\Uuid\HasUuids;
+use Throwable;
/**
* The `$files` object extends the general
@@ -93,6 +96,41 @@ public function changeSort(array $files, int $offset = 0): static
return $this;
}
+ /**
+ * Deletes the files with the given IDs
+ * if they exist in the collection
+ *
+ * @throws \Kirby\Exception\Exception If not all files could be deleted
+ */
+ public function delete(array $ids): void
+ {
+ $exceptions = [];
+
+ // delete all pages and collect errors
+ foreach ($ids as $id) {
+ try {
+ $model = $this->get($id);
+
+ if ($model instanceof File === false) {
+ throw new NotFoundException(
+ key: 'file.undefined'
+ );
+ }
+
+ $model->delete();
+ } catch (Throwable $e) {
+ $exceptions[$id] = $e;
+ }
+ }
+
+ if ($exceptions !== []) {
+ throw new Exception(
+ key: 'file.delete.multiple',
+ details: $exceptions
+ );
+ }
+ }
+
/**
* Creates a files collection from an array of props
*/
diff --git a/src/Cms/Pages.php b/src/Cms/Pages.php
index 4443e431ac..bbb1bbc70d 100644
--- a/src/Cms/Pages.php
+++ b/src/Cms/Pages.php
@@ -2,8 +2,11 @@
namespace Kirby\Cms;
+use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
+use Kirby\Exception\NotFoundException;
use Kirby\Uuid\HasUuids;
+use Throwable;
/**
* The `$pages` object refers to a
@@ -118,6 +121,41 @@ public function code(): Files
return $this->files()->filter('type', 'code');
}
+ /**
+ * Deletes the pages with the given IDs
+ * if they exist in the collection
+ *
+ * @throws \Kirby\Exception\Exception If not all pages could be deleted
+ */
+ public function delete(array $ids): void
+ {
+ $exceptions = [];
+
+ // delete all pages and collect errors
+ foreach ($ids as $id) {
+ try {
+ $model = $this->get($id);
+
+ if ($model instanceof Page === false) {
+ throw new NotFoundException(
+ key: 'page.undefined',
+ );
+ }
+
+ $model->delete();
+ } catch (Throwable $e) {
+ $exceptions[$id] = $e;
+ }
+ }
+
+ if ($exceptions !== []) {
+ throw new Exception(
+ key: 'page.delete.multiple',
+ details: $exceptions
+ );
+ }
+ }
+
/**
* Returns all documents of all children
*/
diff --git a/src/Exception/Exception.php b/src/Exception/Exception.php
index 5d5927e752..2b8cbed421 100644
--- a/src/Exception/Exception.php
+++ b/src/Exception/Exception.php
@@ -172,7 +172,18 @@ final public function getData(): array
*/
final public function getDetails(): array
{
- return $this->details;
+ $details = $this->details;
+
+ foreach ($details as $key => $detail) {
+ if ($detail instanceof Throwable) {
+ $details[$key] = [
+ 'label' => $key,
+ 'message' => $detail->getMessage(),
+ ];
+ }
+ }
+
+ return $details;
}
/**
diff --git a/tests/Cms/Files/FilesTest.php b/tests/Cms/Files/FilesTest.php
index f452aecfc9..f4a76b9022 100644
--- a/tests/Cms/Files/FilesTest.php
+++ b/tests/Cms/Files/FilesTest.php
@@ -2,6 +2,7 @@
namespace Kirby\Cms;
+use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Filesystem\F;
use Kirby\Uuid\Uuids;
@@ -117,6 +118,99 @@ public function testAddInvalidObject()
$files->add($site);
}
+ /**
+ * @covers ::delete
+ */
+ public function testDelete()
+ {
+ $app = new App([
+ 'roots' => [
+ 'index' => static::TMP
+ ],
+ 'site' => [
+ 'files' => [
+ ['filename' => 'b.jpg'],
+ ['filename' => 'a.jpg']
+ ]
+ ]
+ ]);
+
+ $app->impersonate('kirby');
+
+ $files = $app->site()->files();
+
+ $this->assertCount(2, $files);
+
+ $a = $files->get('a.jpg')->root();
+ $b = $files->get('b.jpg')->root();
+
+ // pretend the files exist
+ F::write($a, '');
+ F::write($b, '');
+
+ $this->assertFileExists($a);
+ $this->assertFileExists($b);
+
+ $files->delete([
+ 'a.jpg',
+ 'b.jpg',
+ ]);
+
+ $this->assertCount(0, $files);
+
+ $this->assertFileDoesNotExist($a);
+ $this->assertFileDoesNotExist($b);
+ }
+
+ /**
+ * @covers ::delete
+ */
+ public function testDeleteWithInvalidIds()
+ {
+ $app = new App([
+ 'roots' => [
+ 'index' => static::TMP
+ ],
+ 'site' => [
+ 'files' => [
+ ['filename' => 'b.jpg'],
+ ['filename' => 'a.jpg']
+ ]
+ ]
+ ]);
+
+ $app->impersonate('kirby');
+
+ $files = $app->site()->files();
+
+ $this->assertCount(2, $files);
+
+ $a = $files->get('a.jpg')->root();
+ $b = $files->get('b.jpg')->root();
+
+ // pretend the files exist
+ F::write($a, '');
+ F::write($b, '');
+
+ $this->assertFileExists($a);
+ $this->assertFileExists($b);
+
+ try {
+ $files->delete([
+ 'a.jpg',
+ 'c.jpg',
+ ]);
+ } catch (Exception $e) {
+ $this->assertSame('Not all files could be deleted', $e->getMessage());
+ }
+
+ $this->assertCount(1, $files);
+ $this->assertSame('b.jpg', $files->first()->filename());
+
+ $this->assertFileDoesNotExist($a);
+ $this->assertFileExists($b);
+ }
+
/**
* @covers ::findByKey
* @covers ::findByUuid
diff --git a/tests/Cms/Pages/PagesTest.php b/tests/Cms/Pages/PagesTest.php
index 657bcb3ed3..4b326aa4bd 100644
--- a/tests/Cms/Pages/PagesTest.php
+++ b/tests/Cms/Pages/PagesTest.php
@@ -2,8 +2,13 @@
namespace Kirby\Cms;
+use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
+use Kirby\Filesystem\Dir;
+/**
+ * @coversDefaultClass \Kirby\Cms\Pages
+ */
class PagesTest extends TestCase
{
public const TMP = KIRBY_TMP_DIR . '/Cms.Pages';
@@ -190,6 +195,99 @@ public function testChildren()
$this->assertSame($expected, $pages->children()->keys());
}
+ /**
+ * @covers ::delete
+ */
+ public function testDelete()
+ {
+ $app = new App([
+ 'roots' => [
+ 'index' => static::TMP
+ ],
+ 'site' => [
+ 'children' => [
+ ['slug' => 'a'],
+ ['slug' => 'b']
+ ]
+ ]
+ ]);
+
+ $app->impersonate('kirby');
+
+ $pages = $app->site()->children();
+
+ $this->assertCount(2, $pages);
+
+ $a = $pages->get('a')->root();
+ $b = $pages->get('b')->root();
+
+ // pretend the files exist
+ Dir::make($a);
+ Dir::make($b);
+
+ $this->assertDirectoryExists($a);
+ $this->assertDirectoryExists($b);
+
+ $pages->delete([
+ 'a',
+ 'b',
+ ]);
+
+ $this->assertCount(0, $pages);
+
+ $this->assertDirectoryDoesNotExist($a);
+ $this->assertDirectoryDoesNotExist($b);
+ }
+
+ /**
+ * @covers ::delete
+ */
+ public function testDeleteWithInvalidIds()
+ {
+ $app = new App([
+ 'roots' => [
+ 'index' => static::TMP
+ ],
+ 'site' => [
+ 'children' => [
+ ['slug' => 'a'],
+ ['slug' => 'b']
+ ]
+ ]
+ ]);
+
+ $app->impersonate('kirby');
+
+ $pages = $app->site()->children();
+
+ $this->assertCount(2, $pages);
+
+ $a = $pages->get('a')->root();
+ $b = $pages->get('b')->root();
+
+ // pretend the files exist
+ Dir::make($a);
+ Dir::make($b);
+
+ $this->assertDirectoryExists($a);
+ $this->assertDirectoryExists($b);
+
+ try {
+ $pages->delete([
+ 'a',
+ 'c',
+ ]);
+ } catch (Exception $e) {
+ $this->assertSame('Not all pages could be deleted', $e->getMessage());
+ }
+
+ $this->assertCount(1, $pages);
+ $this->assertSame('b', $pages->first()->slug());
+
+ $this->assertDirectoryDoesNotExist($a);
+ $this->assertDirectoryExists($b);
+ }
+
public function testDocuments()
{
$pages = Pages::factory([
diff --git a/tests/Cms/Sections/FilesSectionTest.php b/tests/Cms/Sections/FilesSectionTest.php
index cec95e304f..84a8bb8ed5 100644
--- a/tests/Cms/Sections/FilesSectionTest.php
+++ b/tests/Cms/Sections/FilesSectionTest.php
@@ -48,6 +48,41 @@ public function testAccept()
$this->assertSame('*', $section->accept());
}
+ public function testBatchDefault()
+ {
+ $section = new Section('files', [
+ 'name' => 'test',
+ 'model' => new Page(['slug' => 'test']),
+ ]);
+
+ $this->assertFalse($section->batch());
+ $this->assertFalse($section->toArray()['options']['batch']);
+ }
+
+ public function testBatchDisabled()
+ {
+ $section = new Section('files', [
+ 'name' => 'test',
+ 'model' => new Page(['slug' => 'test']),
+ 'batch' => false
+ ]);
+
+ $this->assertFalse($section->batch());
+ $this->assertFalse($section->toArray()['options']['batch']);
+ }
+
+ public function testBatchEnabled()
+ {
+ $section = new Section('files', [
+ 'name' => 'test',
+ 'model' => new Page(['slug' => 'test']),
+ 'batch' => true
+ ]);
+
+ $this->assertTrue($section->batch());
+ $this->assertTrue($section->toArray()['options']['batch']);
+ }
+
public function testHeadline()
{
// single headline
diff --git a/tests/Cms/Sections/PagesSectionTest.php b/tests/Cms/Sections/PagesSectionTest.php
index 9e4cdd6859..696420767e 100644
--- a/tests/Cms/Sections/PagesSectionTest.php
+++ b/tests/Cms/Sections/PagesSectionTest.php
@@ -30,6 +30,41 @@ public function tearDown(): void
App::destroy();
}
+ public function testBatchDefault()
+ {
+ $section = new Section('pages', [
+ 'name' => 'test',
+ 'model' => new Page(['slug' => 'test']),
+ ]);
+
+ $this->assertFalse($section->batch());
+ $this->assertFalse($section->toArray()['options']['batch']);
+ }
+
+ public function testBatchDisabled()
+ {
+ $section = new Section('pages', [
+ 'name' => 'test',
+ 'model' => new Page(['slug' => 'test']),
+ 'batch' => false
+ ]);
+
+ $this->assertFalse($section->batch());
+ $this->assertFalse($section->toArray()['options']['batch']);
+ }
+
+ public function testBatchEnabled()
+ {
+ $section = new Section('pages', [
+ 'name' => 'test',
+ 'model' => new Page(['slug' => 'test']),
+ 'batch' => true
+ ]);
+
+ $this->assertTrue($section->batch());
+ $this->assertTrue($section->toArray()['options']['batch']);
+ }
+
public function testHeadline()
{
// single headline
diff --git a/tests/Cms/Sections/mixins/BatchSectionMixinTest.php b/tests/Cms/Sections/mixins/BatchSectionMixinTest.php
new file mode 100644
index 0000000000..6d041eacaa
--- /dev/null
+++ b/tests/Cms/Sections/mixins/BatchSectionMixinTest.php
@@ -0,0 +1,139 @@
+app = new App([
+ 'roots' => [
+ 'index' => static::TMP
+ ],
+ 'translations' => [
+ 'en' => [
+ 'error.section.test.min.plural' => 'The section requires at least {min} items',
+ ]
+ ]
+ ]);
+
+ $this->page = new Page([
+ 'slug' => 'test',
+ 'children' => [
+ ['slug' => 'a'],
+ ['slug' => 'b'],
+ ['slug' => 'c']
+ ]
+ ]);
+
+ Dir::make($this->page->root() . '/a');
+ Dir::make($this->page->root() . '/b');
+ Dir::make($this->page->root() . '/c');
+
+ Section::$types['test'] = [
+ 'mixins' => ['batch'],
+ 'props' => [
+ 'min' => function (int $min = 0) {
+ return $min;
+ }
+ ],
+ 'computed' => [
+ 'models' => function () {
+ return $this->model()->children();
+ },
+ 'total' => function () {
+ return $this->models()->count();
+ },
+ ]
+ ];
+
+ $this->setUpTmp();
+ }
+
+ public function tearDown(): void
+ {
+ $this->tearDownTmp();
+ App::destroy();
+ }
+
+ public function testBatchDisabled()
+ {
+ $section = new Section('test', [
+ 'model' => $this->page,
+ ]);
+
+ $this->assertFalse($section->batch());
+ }
+
+ public function testBatchEnabled()
+ {
+ $section = new Section('test', [
+ 'model' => $this->page,
+ 'batch' => true
+ ]);
+
+ $this->assertTrue($section->batch());
+ }
+
+ public function testDeleteSelectedWithoutIds()
+ {
+ $section = new Section('test', [
+ 'model' => $this->page,
+ 'batch' => true
+ ]);
+
+ $this->assertTrue($section->deleteSelected([]));
+ }
+
+ public function testDeleteSelectedWhenDisabled()
+ {
+ $section = new Section('test', [
+ 'model' => $this->page,
+ 'batch' => false
+ ]);
+
+ $this->expectException(PermissionException::class);
+ $this->expectExceptionMessage('The section does not support batch actions');
+
+ $section->deleteSelected(['test/a', 'test/b']);
+ }
+
+ public function testDeleteSelectedWhenExceedingMin()
+ {
+ $section = new Section('test', [
+ 'model' => $this->page,
+ 'batch' => true,
+ 'min' => 2
+ ]);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('The section requires at least 2 items');
+
+ $section->deleteSelected([
+ 'test/a', 'test/b', 'test/c'
+ ]);
+ }
+
+ public function testDeleteSelected()
+ {
+ $section = new Section('test', [
+ 'model' => $this->page,
+ 'batch' => true,
+ ]);
+
+ $this->app->impersonate('kirby');
+
+ $this->assertTrue($section->deleteSelected([
+ 'test/a', 'test/b', 'test/c'
+ ]));
+ }
+}
diff --git a/tests/Exception/ExceptionTest.php b/tests/Exception/ExceptionTest.php
index c58ff729e8..b61a9ea5c9 100644
--- a/tests/Exception/ExceptionTest.php
+++ b/tests/Exception/ExceptionTest.php
@@ -98,6 +98,44 @@ public function testDefaults()
$this->assertSame([], $exception->getDetails());
}
+ /**
+ * @covers ::getDetails
+ */
+ public function testGetDetails()
+ {
+ $exception = new Exception(
+ details: $details = [
+ [
+ 'label' => 'A',
+ 'message' => 'Message A',
+ ]
+ ]
+ );
+
+ $this->assertSame($details, $exception->getDetails());
+ }
+
+ /**
+ * @covers ::getDetails
+ */
+ public function testGetDetailsWithExceptions()
+ {
+ $exception = new Exception(
+ details: [
+ 'A' => new Exception(message: 'Message A')
+ ]
+ );
+
+ $expected = [
+ 'A' => [
+ 'label' => 'A',
+ 'message' => 'Message A',
+ ]
+ ];
+
+ $this->assertSame($expected, $exception->getDetails());
+ }
+
/**
* @covers ::__construct
*/