feat: improve category/header sort order

This commit is contained in:
2025-11-09 21:53:57 +02:00
parent e733933252
commit 9d73614ea5
5 changed files with 491 additions and 151 deletions

View File

@@ -192,4 +192,32 @@ class CatHeaderController extends OCSController {
return new DataResponse(['error' => 'Failed to delete category header'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Reorder category headers
*
* @param list<array{id: int, sortOrder: int}> $headers Array of headers with new sort orders
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
*
* 200: Headers reordered successfully
*/
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'POST', url: '/api/headers/reorder')]
public function reorder(array $headers): DataResponse {
try {
foreach ($headers as $headerData) {
$header = $this->catHeaderMapper->find($headerData['id']);
$header->setSortOrder($headerData['sortOrder']);
$this->catHeaderMapper->update($header);
}
return new DataResponse(['success' => true]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Header not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error reordering headers: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to reorder headers'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -353,4 +353,32 @@ class CategoryController extends OCSController {
return new DataResponse(['error' => 'Failed to update permissions'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Reorder categories
*
* @param list<array{id: int, sortOrder: int}> $categories Array of categories with new sort orders
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
*
* 200: Categories reordered successfully
*/
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'POST', url: '/api/categories/reorder')]
public function reorder(array $categories): DataResponse {
try {
foreach ($categories as $categoryData) {
$category = $this->categoryMapper->find($categoryData['id']);
$category->setSortOrder($categoryData['sortOrder']);
$this->categoryMapper->update($category);
}
return new DataResponse(['success' => true]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Category not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error reordering categories: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to reorder categories'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -1915,6 +1915,139 @@
}
}
},
"/ocs/v2.php/apps/forum/api/headers/reorder": {
"post": {
"operationId": "cat_header-reorder",
"summary": "Reorder category headers",
"tags": [
"cat_header"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"headers"
],
"properties": {
"headers": {
"type": "array",
"description": "Array of headers with new sort orders",
"items": {
"type": "object",
"required": [
"id",
"sortOrder"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"sortOrder": {
"type": "integer",
"format": "int64"
}
}
}
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Headers reordered successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"success"
],
"properties": {
"success": {
"type": "boolean"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/categories": {
"get": {
"operationId": "category-index",
@@ -3064,6 +3197,139 @@
}
}
},
"/ocs/v2.php/apps/forum/api/categories/reorder": {
"post": {
"operationId": "category-reorder",
"summary": "Reorder categories",
"tags": [
"category"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"categories"
],
"properties": {
"categories": {
"type": "array",
"description": "Array of categories with new sort orders",
"items": {
"type": "object",
"required": [
"id",
"sortOrder"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"sortOrder": {
"type": "integer",
"format": "int64"
}
}
}
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Categories reordered successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"success"
],
"properties": {
"success": {
"type": "boolean"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/users": {
"get": {
"operationId": "forum_user-index",

View File

@@ -86,16 +86,6 @@
:rows="3"
/>
</div>
<div class="form-group">
<NcTextField
v-model.number="formData.sortOrder"
:label="strings.sortOrder"
:placeholder="strings.sortOrderPlaceholder"
type="number"
/>
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
</div>
</div>
</section>
@@ -173,16 +163,6 @@
:rows="2"
/>
</div>
<div class="form-group">
<NcTextField
v-model.number="headerDialog.sortOrder"
:label="strings.headerSortOrder"
:placeholder="strings.sortOrderPlaceholder"
type="number"
/>
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
</div>
</div>
<template #actions>
@@ -249,7 +229,6 @@ export default defineComponent({
name: '',
slug: '',
description: '',
sortOrder: 0,
},
headerDialog: {
show: false,
@@ -258,7 +237,6 @@ export default defineComponent({
id: null as number | null,
name: '',
description: '',
sortOrder: 0,
},
strings: {
@@ -376,7 +354,6 @@ export default defineComponent({
this.formData.name = category.name
this.formData.slug = category.slug
this.formData.description = category.description || ''
this.formData.sortOrder = category.sortOrder || 0
// Set selectedHeader based on headerId
const header = this.headers.find((h) => h.id === category.headerId)
@@ -439,7 +416,6 @@ export default defineComponent({
name: this.formData.name.trim(),
slug: this.formData.slug.trim(),
description: this.formData.description.trim() || null,
sortOrder: this.formData.sortOrder,
}
let categoryId: number
@@ -498,7 +474,6 @@ export default defineComponent({
this.headerDialog.id = null
this.headerDialog.name = ''
this.headerDialog.description = ''
this.headerDialog.sortOrder = 0
},
editHeader(): void {
@@ -512,7 +487,6 @@ export default defineComponent({
this.headerDialog.id = header.id
this.headerDialog.name = header.name
this.headerDialog.description = header.description || ''
this.headerDialog.sortOrder = header.sortOrder || 0
},
async saveHeader(): Promise<void> {
@@ -524,7 +498,6 @@ export default defineComponent({
const headerData = {
name: this.headerDialog.name.trim(),
description: this.headerDialog.description.trim() || null,
sortOrder: this.headerDialog.sortOrder,
}
let headerId: number
@@ -541,7 +514,6 @@ export default defineComponent({
...this.headers[index],
name: headerData.name,
description: headerData.description,
sortOrder: headerData.sortOrder,
}
}
} else {

View File

@@ -44,13 +44,24 @@
</div>
<div v-if="categoryHeaders.length > 0" class="headers-table">
<div v-for="header in categoryHeaders" :key="`header-manage-${header.id}`" class="header-manage-row">
<div v-for="(header, index) in categoryHeaders" :key="`header-manage-${header.id}`" class="header-manage-row">
<div class="header-sort-buttons">
<NcButton v-if="index > 0" type="tertiary" @click="moveHeaderUp(index)" :aria-label="strings.moveUp" :title="strings.moveUp">
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton v-if="index < categoryHeaders.length - 1" type="tertiary" @click="moveHeaderDown(index)"
:aria-label="strings.moveDown" :title="strings.moveDown">
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="header-info">
<div class="header-name">{{ header.name }}</div>
<div v-if="header.description" class="header-desc muted">{{ header.description }}</div>
<div class="header-meta muted">
<span>Sort: {{ header.sortOrder || 0 }}</span>
<span></span>
<span>{{ (header.categories?.length || 0) }} {{ strings.categoriesCount }}</span>
</div>
</div>
@@ -61,11 +72,7 @@
</template>
{{ strings.edit }}
</NcButton>
<NcButton
type="error"
:disabled="categoryHeaders.length <= 1"
@click="confirmDeleteHeader(header)"
>
<NcButton type="error" :disabled="categoryHeaders.length <= 1" @click="confirmDeleteHeader(header)">
<template #icon>
<DeleteIcon :size="20" />
</template>
@@ -91,48 +98,58 @@
<span v-if="header.description" class="muted">{{ header.description }}</span>
</div>
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
<div v-for="category in header.categories" :key="category.id" class="category-row">
<div class="category-info">
<div class="category-name">{{ category.name }}</div>
<div v-if="category.description" class="category-desc muted">{{ category.description }}</div>
<div class="category-meta muted">
<span>Slug: {{ category.slug }}</span>
<span></span>
<span>Threads: {{ category.threadCount || 0 }}</span>
<span></span>
<span>Posts: {{ category.postCount || 0 }}</span>
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
<div v-for="(category, index) in header.categories" :key="category.id" class="category-row">
<div class="category-sort-buttons">
<NcButton v-if="index > 0" type="tertiary" @click="moveCategoryUp(header.id, index)"
:aria-label="strings.moveUp" :title="strings.moveUp">
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton v-if="index < header.categories.length - 1" type="tertiary"
@click="moveCategoryDown(header.id, index)" :aria-label="strings.moveDown" :title="strings.moveDown">
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="category-info">
<div class="category-name">{{ category.name }}</div>
<div v-if="category.description" class="category-desc muted">{{ category.description }}</div>
<div class="category-meta muted">
<span>Slug: {{ category.slug }}</span>
<span></span>
<span>Threads: {{ category.threadCount || 0 }}</span>
<span></span>
<span>Posts: {{ category.postCount || 0 }}</span>
</div>
</div>
<div class="category-actions">
<NcButton @click="editCategory(category.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton type="error" @click="confirmDelete(category)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
<div class="category-actions">
<NcButton @click="editCategory(category.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton type="error" @click="confirmDelete(category)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</div>
<div v-else class="no-categories muted">
{{ strings.noCategories }}
</div>
</template>
<div v-else class="no-categories muted">
{{ strings.noCategories }}
</div>
</template>
</section>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<NcDialog v-if="deleteDialog.show" :name="strings.deleteDialogTitle" @close="deleteDialog.show = false">
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.category?.name || '') }}</p>
@@ -145,34 +162,19 @@
<h4>{{ strings.whatToDoWithThreads }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="migrate"
type="radio"
name="delete-action"
>
<NcCheckboxRadioSwitch v-model="deleteDialog.action" value="migrate" type="radio" name="delete-action">
{{ strings.migrateThreads }}
</NcCheckboxRadioSwitch>
<div v-if="deleteDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetCategory }}</label>
<NcSelect
v-model="selectedTargetCategory"
:options="targetCategoryOptions"
:placeholder="strings.selectCategory"
label="label"
track-by="id"
/>
<NcSelect v-model="selectedTargetCategory" :options="targetCategoryOptions"
:placeholder="strings.selectCategory" label="label" track-by="id" />
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="delete"
type="radio"
name="delete-action"
>
<NcCheckboxRadioSwitch v-model="deleteDialog.action" value="delete" type="radio" name="delete-action">
{{ strings.softDeleteThreads }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.softDeleteHelp }}</p>
@@ -184,48 +186,31 @@
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
type="error"
:disabled="deleteDialog.action === 'migrate' && !selectedTargetCategory"
@click="executeDelete"
>
<NcButton type="error" :disabled="deleteDialog.action === 'migrate' && !selectedTargetCategory"
@click="executeDelete">
{{ strings.deleteCategory }}
</NcButton>
</template>
</NcDialog>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
<NcDialog v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
@close="headerDialog.show = false">
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
<NcTextField v-model="headerDialog.name" :label="strings.headerName"
:placeholder="strings.headerNamePlaceholder" :required="true" />
</div>
<div class="form-group">
<NcTextArea
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
<NcTextArea v-model="headerDialog.description" :label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder" :rows="2" />
</div>
<div class="form-group">
<NcTextField
v-model.number="headerDialog.sortOrder"
:label="strings.headerSortOrder"
:placeholder="strings.sortOrderPlaceholder"
type="number"
/>
<NcTextField v-model.number="headerDialog.sortOrder" :label="strings.headerSortOrder"
:placeholder="strings.sortOrderPlaceholder" type="number" />
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
</div>
</div>
@@ -234,11 +219,7 @@
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
type="primary"
:disabled="!headerDialog.name.trim()"
@click="saveHeader"
>
<NcButton type="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
@@ -248,11 +229,7 @@
</NcDialog>
<!-- Header Delete Confirmation Dialog -->
<NcDialog
v-if="deleteHeaderDialog.show"
:name="strings.deleteHeaderTitle"
@close="deleteHeaderDialog.show = false"
>
<NcDialog v-if="deleteHeaderDialog.show" :name="strings.deleteHeaderTitle" @close="deleteHeaderDialog.show = false">
<div class="delete-dialog-content">
<p>{{ strings.deleteHeaderMessage(deleteHeaderDialog.header?.name || '') }}</p>
@@ -265,34 +242,21 @@
<h4>{{ strings.whatToDoWithCategories }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="migrate"
type="radio"
name="delete-header-action"
>
<NcCheckboxRadioSwitch v-model="deleteHeaderDialog.action" value="migrate" type="radio"
name="delete-header-action">
{{ strings.migrateCategories }}
</NcCheckboxRadioSwitch>
<div v-if="deleteHeaderDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetHeader }}</label>
<NcSelect
v-model="selectedTargetHeader"
:options="targetHeaderOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
/>
<NcSelect v-model="selectedTargetHeader" :options="targetHeaderOptions"
:placeholder="strings.selectHeader" label="label" track-by="id" />
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="delete"
type="radio"
name="delete-header-action"
>
<NcCheckboxRadioSwitch v-model="deleteHeaderDialog.action" value="delete" type="radio"
name="delete-header-action">
{{ strings.deleteCategories }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.deleteCategoriesHelp }}</p>
@@ -304,11 +268,8 @@
<NcButton @click="deleteHeaderDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
type="error"
:disabled="deleteHeaderDialog.action === 'migrate' && !selectedTargetHeader"
@click="executeDeleteHeader"
>
<NcButton type="error" :disabled="deleteHeaderDialog.action === 'migrate' && !selectedTargetHeader"
@click="executeDeleteHeader">
{{ strings.deleteHeader }}
</NcButton>
</template>
@@ -325,6 +286,8 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import ChevronUpIcon from '@icons/ChevronUp.vue'
import ChevronDownIcon from '@icons/ChevronDown.vue'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import PlusIcon from '@icons/Plus.vue'
import PencilIcon from '@icons/Pencil.vue'
@@ -349,6 +312,8 @@ export default defineComponent({
PencilIcon,
DeleteIcon,
InformationIcon,
ChevronUpIcon,
ChevronDownIcon,
},
data() {
return {
@@ -430,6 +395,8 @@ export default defineComponent({
deleteCategoriesHelp: t('forum', 'All categories and their threads will be permanently deleted'),
selectTargetHeader: t('forum', 'Select target header'),
selectHeader: t('forum', '-- Select a header --'),
moveUp: t('forum', 'Move up'),
moveDown: t('forum', 'Move down'),
},
}
},
@@ -613,6 +580,85 @@ export default defineComponent({
// TODO: Show error notification
}
},
async moveHeaderUp(index: number): Promise<void> {
if (index <= 0) return
// Update sort orders on backend
await this.updateHeaderSortOrders(index, -1)
},
async moveHeaderDown(index: number): Promise<void> {
if (index >= this.categoryHeaders.length - 1) return
// Update sort orders on backend
await this.updateHeaderSortOrders(index, 1)
},
async updateHeaderSortOrders(index: number, amount: number): Promise<void> {
// Swap positions locally
const temp = this.categoryHeaders[index]
this.categoryHeaders[index] = this.categoryHeaders[index + amount]
this.categoryHeaders[index + amount] = temp
try {
// Build array of header IDs in their current order
const sortOrders = this.categoryHeaders.map((header, idx) => ({
id: header.id,
sortOrder: idx,
}))
await ocs.post('/headers/reorder', { headers: sortOrders })
} catch (e) {
console.error('Failed to update header sort orders', e)
// Revert the swap on error
const revertTemp = this.categoryHeaders[index + amount]
this.categoryHeaders[index + amount] = this.categoryHeaders[index]
this.categoryHeaders[index] = revertTemp
}
},
async moveCategoryUp(headerId: number, index: number): Promise<void> {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories || index <= 0) return
// Update sort orders on backend
await this.updateCategorySortOrders(headerId, index, -1)
},
async moveCategoryDown(headerId: number, index: number): Promise<void> {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories || index >= header.categories.length - 1) return
// Update sort orders on backend
await this.updateCategorySortOrders(headerId, index, 1)
},
async updateCategorySortOrders(headerId: number, index: number, amount: number): Promise<void> {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories) return
// Swap positions locally
const temp = header.categories[index]
header.categories[index] = header.categories[index + amount]
header.categories[index + amount] = temp
try {
// Build array of category IDs in their current order
const sortOrders = header.categories.map((category, idx) => ({
id: category.id,
sortOrder: idx,
}))
await ocs.post('/categories/reorder', { categories: sortOrders })
} catch (e) {
console.error('Failed to update category sort orders', e)
// Revert the swap on error
const revertTemp = header.categories[index + amount]
header.categories[index + amount] = header.categories[index]
header.categories[index] = revertTemp
}
},
},
})
</script>