feat: allow category nesting

This commit is contained in:
2026-04-02 01:44:23 +03:00
parent b36d82fbef
commit cb5c0ca44c
27 changed files with 2417 additions and 314 deletions

View File

@@ -78,13 +78,25 @@ class CategoryController extends OCSController {
}
}
// Group accessible categories by header_id
// Build a lookup map for resolving effective header IDs
$allCatsById = [];
foreach ($allCategories as $category) {
$allCatsById[$category->getId()] = $category;
}
// Group accessible categories by effective header_id
// Child categories inherit the header from their root ancestor
$categoriesByHeader = [];
foreach ($allCategories as $category) {
if (!in_array($category->getId(), $accessibleCategoryIds, true)) {
continue;
}
$headerId = $category->getHeaderId();
// Walk up the parent chain to find the effective header
$current = $category;
while ($current->getParentId() !== null && isset($allCatsById[$current->getParentId()])) {
$current = $allCatsById[$current->getParentId()];
}
$headerId = $current->getHeaderId();
if (!isset($categoriesByHeader[$headerId])) {
$categoriesByHeader[$headerId] = [];
}
@@ -190,13 +202,15 @@ class CategoryController extends OCSController {
/**
* Create a new category
*
* @param int $headerId Category header ID
* @param int|null $headerId Category header ID (required for top-level categories)
* @param string $name Category name
* @param string $slug Category slug
* @param string|null $description Category description
* @param int $sortOrder Sort order
* @param string|null $color Category color (hex, e.g. #dc2626)
* @param string|null $textColor Text color mode ('light' or 'dark')
* @param int|null $parentId Parent category ID (null for top-level categories)
* @param bool $hideChildrenOnCard Whether to hide child categories on the parent card
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
*
* 201: Category created
@@ -204,16 +218,32 @@ class CategoryController extends OCSController {
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'POST', url: '/api/categories')]
public function create(int $headerId, string $name, string $slug, ?string $description = null, int $sortOrder = 0, ?string $color = null, ?string $textColor = null): DataResponse {
public function create(?int $headerId = null, string $name = '', string $slug = '', ?string $description = null, int $sortOrder = 0, ?string $color = null, ?string $textColor = null, ?int $parentId = null, bool $hideChildrenOnCard = false): DataResponse {
try {
// Validate: either headerId (top-level) or parentId (child) must be set
if ($parentId !== null) {
// Validate parent exists
try {
$this->categoryMapper->find($parentId);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Parent category not found'], Http::STATUS_NOT_FOUND);
}
// Child categories don't have their own header
$headerId = null;
} elseif ($headerId === null) {
return new DataResponse(['error' => 'Either headerId or parentId must be provided'], Http::STATUS_BAD_REQUEST);
}
$category = new \OCA\Forum\Db\Category();
$category->setHeaderId($headerId);
$category->setParentId($parentId);
$category->setName($name);
$category->setSlug($slug);
$category->setDescription($description);
$category->setSortOrder($sortOrder);
$category->setColor($color);
$category->setTextColor($textColor);
$category->setHideChildrenOnCard($hideChildrenOnCard);
$category->setThreadCount(0);
$category->setPostCount(0);
$category->setCreatedAt(time());
@@ -239,6 +269,8 @@ class CategoryController extends OCSController {
* @param int|null $sortOrder Sort order
* @param string|null $color Category color (hex, e.g. #dc2626)
* @param string|null $textColor Text color mode ('light' or 'dark')
* @param string|null $parentId Parent category ID ('__unset__' = not provided, null = top-level, int = child)
* @param bool|null $hideChildrenOnCard Whether to hide child categories on the parent card
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Category updated
@@ -246,13 +278,49 @@ class CategoryController extends OCSController {
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'PUT', url: '/api/categories/{id}')]
public function update(int $id, ?int $headerId = null, ?string $name = null, ?string $description = null, ?string $slug = null, ?int $sortOrder = null, ?string $color = '__unset__', ?string $textColor = '__unset__'): DataResponse {
public function update(int $id, ?int $headerId = null, ?string $name = null, ?string $description = null, ?string $slug = null, ?int $sortOrder = null, ?string $color = '__unset__', ?string $textColor = '__unset__', string|int|null $parentId = '__unset__', ?bool $hideChildrenOnCard = null): DataResponse {
try {
$category = $this->categoryMapper->find($id);
if ($headerId !== null) {
// Handle parentId changes
if ($parentId !== '__unset__') {
if ($parentId !== null) {
$parentIdInt = (int)$parentId;
// Validate parent exists
try {
$this->categoryMapper->find($parentIdInt);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Parent category not found'], Http::STATUS_NOT_FOUND);
}
// Prevent circular references: walk up from proposed parent
$current = $parentIdInt;
while ($current !== null) {
if ($current === $id) {
return new DataResponse(['error' => 'Cannot set a descendant as parent (circular reference)'], Http::STATUS_BAD_REQUEST);
}
try {
$parentCat = $this->categoryMapper->find($current);
$current = $parentCat->getParentId();
} catch (DoesNotExistException $e) {
break;
}
}
$category->setParentId($parentIdInt);
$category->setHeaderId(null);
} else {
// Moving to top-level: need a headerId
$category->setParentId(null);
if ($headerId !== null) {
$category->setHeaderId($headerId);
}
}
} elseif ($headerId !== null) {
$category->setHeaderId($headerId);
}
if ($name !== null) {
$category->setName($name);
}
@@ -271,6 +339,9 @@ class CategoryController extends OCSController {
if ($textColor !== '__unset__') {
$category->setTextColor($textColor);
}
if ($hideChildrenOnCard !== null) {
$category->setHideChildrenOnCard($hideChildrenOnCard);
}
$category->setUpdatedAt(time());
/** @var \OCA\Forum\Db\Category */
@@ -324,6 +395,18 @@ class CategoryController extends OCSController {
try {
$category = $this->categoryMapper->find($id);
// Re-parent children: move direct children to this category's parent
$children = $this->categoryMapper->findByParentId($id);
foreach ($children as $child) {
$child->setParentId($category->getParentId());
// If deleted category was top-level, children become top-level under the same header
if ($category->getParentId() === null) {
$child->setHeaderId($category->getHeaderId());
}
$child->setUpdatedAt(time());
$this->categoryMapper->update($child);
}
$threadsAffected = 0;
// Handle threads migration or soft-delete

View File

@@ -16,6 +16,8 @@ use OCP\AppFramework\Db\Entity;
* @method void setId(int $value)
* @method int getHeaderId()
* @method void setHeaderId(int $value)
* @method int|null getParentId()
* @method void setParentId(?int $value)
* @method string getName()
* @method void setName(string $value)
* @method string|null getDescription()
@@ -32,6 +34,8 @@ use OCP\AppFramework\Db\Entity;
* @method void setColor(?string $value)
* @method string|null getTextColor()
* @method void setTextColor(?string $value)
* @method bool getHideChildrenOnCard()
* @method void setHideChildrenOnCard(bool $value)
* @method int getCreatedAt()
* @method void setCreatedAt(int $value)
* @method int getUpdatedAt()
@@ -39,12 +43,14 @@ use OCP\AppFramework\Db\Entity;
*/
class Category extends Entity implements JsonSerializable {
protected $headerId;
protected $parentId;
protected $name;
protected $description;
protected $slug;
protected $sortOrder;
protected $color;
protected $textColor;
protected $hideChildrenOnCard;
protected $threadCount;
protected $postCount;
protected $createdAt;
@@ -53,12 +59,14 @@ class Category extends Entity implements JsonSerializable {
public function __construct() {
$this->addType('id', 'integer');
$this->addType('headerId', 'integer');
$this->addType('parentId', 'integer');
$this->addType('name', 'string');
$this->addType('description', 'string');
$this->addType('slug', 'string');
$this->addType('sortOrder', 'integer');
$this->addType('color', 'string');
$this->addType('textColor', 'string');
$this->addType('hideChildrenOnCard', 'boolean');
$this->addType('threadCount', 'integer');
$this->addType('postCount', 'integer');
$this->addType('createdAt', 'integer');
@@ -69,12 +77,14 @@ class Category extends Entity implements JsonSerializable {
return [
'id' => $this->getId(),
'headerId' => $this->getHeaderId(),
'parentId' => $this->getParentId(),
'name' => $this->getName(),
'description' => $this->getDescription(),
'slug' => $this->getSlug(),
'sortOrder' => $this->getSortOrder(),
'color' => $this->getColor(),
'textColor' => $this->getTextColor(),
'hideChildrenOnCard' => (bool)$this->getHideChildrenOnCard(),
'threadCount' => $this->getThreadCount(),
'postCount' => $this->getPostCount(),
'createdAt' => $this->getCreatedAt(),

View File

@@ -119,6 +119,46 @@ class CategoryMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Find direct children of a category
*
* @param int $parentId Parent category ID
* @return array<Category>
*/
public function findByParentId(int $parentId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()
->eq('parent_id', $qb->createNamedParameter($parentId, IQueryBuilder::PARAM_INT))
)
->orderBy('sort_order', 'ASC');
return $this->findEntities($qb);
}
/**
* Find all descendants of a category (iterative breadth-first)
*
* @param int $categoryId Root category ID
* @return array<Category> All descendants (not including the root)
*/
public function findChildren(int $categoryId): array {
$allChildren = [];
$queue = [$categoryId];
while (!empty($queue)) {
$currentId = array_shift($queue);
$children = $this->findByParentId($currentId);
foreach ($children as $child) {
$allChildren[] = $child;
$queue[] = $child->getId();
}
}
return $allChildren;
}
/**
* Move all categories from one header to another
*

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Version 29 Migration:
* - Add parent_id column to forum_categories for subcategory support
* - Add hide_children_on_card column to forum_categories
* - Make header_id nullable (child categories don't have their own header)
*/
class Version29Date20260402000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('forum_categories')) {
$table = $schema->getTable('forum_categories');
// Make header_id nullable - child categories inherit header from parent chain
if ($table->hasColumn('header_id')) {
$column = $table->getColumn('header_id');
$column->setNotnull(false);
$column->setDefault(null);
}
if (!$table->hasColumn('parent_id')) {
$table->addColumn('parent_id', 'integer', [
'notnull' => false,
'unsigned' => true,
'default' => null,
]);
$table->addIndex(['parent_id'], 'forum_cat_parent_id_idx');
}
if (!$table->hasColumn('hide_children_on_card')) {
$table->addColumn('hide_children_on_card', 'boolean', [
'notnull' => false,
'default' => false,
]);
}
}
return $schema;
}
}

View File

@@ -266,13 +266,33 @@ class PermissionService {
// Get all categories
$categories = $this->categoryMapper->findAll();
// Check view permission for each category
// Check view permission for each category (own permission only)
foreach ($categories as $category) {
if ($this->hasCategoryPermission($userId, $category->getId(), 'canView')) {
$accessibleCategoryIds[] = $category->getId();
}
}
return $accessibleCategoryIds;
// Build parent map for ancestor chain checking
$parentMap = [];
foreach ($categories as $category) {
$parentMap[$category->getId()] = $category->getParentId();
}
// Prune categories whose ancestor chain is not fully accessible
$accessibleSet = array_flip($accessibleCategoryIds);
$accessibleCategoryIds = array_filter($accessibleCategoryIds, function ($catId) use ($parentMap, $accessibleSet) {
$current = $parentMap[$catId] ?? null;
while ($current !== null) {
if (!isset($accessibleSet[$current])) {
return false;
}
$current = $parentMap[$current] ?? null;
}
return true;
});
return array_values($accessibleCategoryIds);
} catch (\Exception $e) {
$this->logger->error('Error getting accessible categories: ' . $e->getMessage());
return [];

View File

@@ -3055,28 +3055,27 @@
}
],
"requestBody": {
"required": true,
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"headerId",
"name",
"slug"
],
"properties": {
"headerId": {
"type": "integer",
"format": "int64",
"description": "Category header ID"
"nullable": true,
"default": null,
"description": "Category header ID (required for top-level categories)"
},
"name": {
"type": "string",
"default": "",
"description": "Category name"
},
"slug": {
"type": "string",
"default": "",
"description": "Category slug"
},
"description": {
@@ -3102,6 +3101,18 @@
"nullable": true,
"default": null,
"description": "Text color mode ('light' or 'dark')"
},
"parentId": {
"type": "integer",
"format": "int64",
"nullable": true,
"default": null,
"description": "Parent category ID (null for top-level categories)"
},
"hideChildrenOnCard": {
"type": "boolean",
"default": false,
"description": "Whether to hide child categories on the parent card"
}
}
}
@@ -3424,6 +3435,18 @@
"nullable": true,
"default": "__unset__",
"description": "Text color mode ('light' or 'dark')"
},
"parentId": {
"type": "string",
"nullable": true,
"default": "__unset__",
"description": "Parent category ID ('__unset__' = not provided, null = top-level, int = child)"
},
"hideChildrenOnCard": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Whether to hide child categories on the parent card"
}
}
}

View File

@@ -3055,28 +3055,27 @@
}
],
"requestBody": {
"required": true,
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"headerId",
"name",
"slug"
],
"properties": {
"headerId": {
"type": "integer",
"format": "int64",
"description": "Category header ID"
"nullable": true,
"default": null,
"description": "Category header ID (required for top-level categories)"
},
"name": {
"type": "string",
"default": "",
"description": "Category name"
},
"slug": {
"type": "string",
"default": "",
"description": "Category slug"
},
"description": {
@@ -3102,6 +3101,18 @@
"nullable": true,
"default": null,
"description": "Text color mode ('light' or 'dark')"
},
"parentId": {
"type": "integer",
"format": "int64",
"nullable": true,
"default": null,
"description": "Parent category ID (null for top-level categories)"
},
"hideChildrenOnCard": {
"type": "boolean",
"default": false,
"description": "Whether to hide child categories on the parent card"
}
}
}
@@ -3424,6 +3435,18 @@
"nullable": true,
"default": "__unset__",
"description": "Text color mode ('light' or 'dark')"
},
"parentId": {
"type": "string",
"nullable": true,
"default": "__unset__",
"description": "Parent category ID ('__unset__' = not provided, null = top-level, int = child)"
},
"hideChildrenOnCard": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Whether to hide child categories on the parent card"
}
}
}

View File

@@ -65,17 +65,13 @@
<!-- Categories under each header -->
<template v-if="isHeaderOpen(header.id)">
<NcAppNavigationItem
<NavCategoryItem
v-for="category in header.categories"
:key="`category-${category.id}`"
:name="category.name"
:to="{ path: `/c/${category.slug}` }"
:category="category"
:active="isCategoryActive(category)"
>
<template #icon>
<ForumIcon :size="20" />
</template>
</NcAppNavigationItem>
:active-category-ids="activeCategoryIds"
/>
</template>
</NcAppNavigationItem>
@@ -229,8 +225,8 @@ import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSear
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import UserInfo from '@/components/UserInfo'
import NavCategoryItem from './NavCategoryItem.vue'
import HomeIcon from '@icons/Home.vue'
import ForumIcon from '@icons/Forum.vue'
import FolderIcon from '@icons/Folder.vue'
import MagnifyIcon from '@icons/Magnify.vue'
import BookmarkIcon from '@icons/Bookmark.vue'
@@ -260,8 +256,8 @@ export default defineComponent({
NcActionButton,
NcLoadingIcon,
UserInfo,
NavCategoryItem,
HomeIcon,
ForumIcon,
FolderIcon,
MagnifyIcon,
BookmarkIcon,
@@ -277,7 +273,7 @@ export default defineComponent({
AccountCogIcon,
},
setup() {
const { categoryHeaders, fetchCategories } = useCategories()
const { categoryHeaders, fetchCategories, getAllCategoriesFlat } = useCategories()
const { userId, displayName, fetchCurrentUser } = useCurrentUser()
const {
canAccessAdmin,
@@ -294,6 +290,7 @@ export default defineComponent({
return {
categoryHeaders,
fetchCategories,
getAllCategoriesFlat,
fetchCurrentUser,
userId,
displayName,
@@ -371,6 +368,18 @@ export default defineComponent({
this.isLoading = false
}
},
computed: {
activeCategoryIds(): Set<number> {
const ids = new Set<number>()
const allCats = this.getAllCategoriesFlat()
for (const cat of allCats) {
if (this.isCategoryActive(cat)) {
ids.add(cat.id)
}
}
return ids
},
},
methods: {
loadNavigationState(): void {
try {

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockCategory } from '@/test-mocks'
// Mock NcAppNavigationItem to render children in a slot
vi.mock('@nextcloud/vue/components/NcAppNavigationItem', () =>
createComponentMock('NcAppNavigationItem', {
template: '<div class="nav-item-mock" :data-name="name"><slot name="icon" /><slot /></div>',
props: ['name', 'to', 'active'],
}),
)
vi.mock('@icons/Forum.vue', () => createIconMock('ForumIcon'))
import NavCategoryItem from './NavCategoryItem.vue'
describe('NavCategoryItem', () => {
it('should render the category name', () => {
const category = createMockCategory({ id: 1, name: 'General' })
const wrapper = mount(NavCategoryItem, {
props: { category, active: false, activeCategoryIds: new Set<number>() },
})
expect(wrapper.find('[data-name="General"]').exists()).toBe(true)
})
it('should render direct children', () => {
const child1 = createMockCategory({ id: 2, name: 'Child 1', parentId: 1, slug: 'child-1' })
const child2 = createMockCategory({ id: 3, name: 'Child 2', parentId: 1, slug: 'child-2' })
const parent = createMockCategory({
id: 1,
name: 'Parent',
children: [child1, child2],
})
const wrapper = mount(NavCategoryItem, {
props: { category: parent, active: false, activeCategoryIds: new Set<number>() },
})
const items = wrapper.findAll('.nav-item-mock')
// Parent + 2 children = 3 items
expect(items).toHaveLength(3)
expect(wrapper.find('[data-name="Child 1"]').exists()).toBe(true)
expect(wrapper.find('[data-name="Child 2"]').exists()).toBe(true)
})
it('should render grandchildren recursively', () => {
const grandchild = createMockCategory({
id: 3,
name: 'Grandchild',
parentId: 2,
slug: 'grandchild',
})
const child = createMockCategory({
id: 2,
name: 'Child',
parentId: 1,
slug: 'child',
children: [grandchild],
})
const parent = createMockCategory({
id: 1,
name: 'Parent',
children: [child],
})
const wrapper = mount(NavCategoryItem, {
props: { category: parent, active: false, activeCategoryIds: new Set<number>() },
})
const items = wrapper.findAll('.nav-item-mock')
// Parent + child + grandchild = 3 items
expect(items).toHaveLength(3)
expect(wrapper.find('[data-name="Grandchild"]').exists()).toBe(true)
})
it('should not render children when there are none', () => {
const category = createMockCategory({ id: 1, name: 'Leaf', children: [] })
const wrapper = mount(NavCategoryItem, {
props: { category, active: false, activeCategoryIds: new Set<number>() },
})
const items = wrapper.findAll('.nav-item-mock')
expect(items).toHaveLength(1)
})
})

View File

@@ -0,0 +1,47 @@
<template>
<NcAppNavigationItem :name="category.name" :to="{ path: `/c/${category.slug}` }" :active="active">
<template #icon>
<ForumIcon :size="20" />
</template>
<!-- Recursive children -->
<template v-if="category.children && category.children.length > 0">
<NavCategoryItem
v-for="child in category.children"
:key="`category-${child.id}`"
:category="child"
:active="activeCategoryIds.has(child.id)"
:active-category-ids="activeCategoryIds"
/>
</template>
</NcAppNavigationItem>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import ForumIcon from '@icons/Forum.vue'
import type { Category } from '@/types'
export default defineComponent({
name: 'NavCategoryItem',
components: {
NcAppNavigationItem,
ForumIcon,
},
props: {
category: {
type: Object as PropType<Category>,
required: true,
},
active: {
type: Boolean,
default: false,
},
activeCategoryIds: {
type: Set as unknown as PropType<Set<number>>,
default: () => new Set(),
},
},
})
</script>

View File

@@ -77,6 +77,53 @@ describe('CategoryCard', () => {
})
})
describe('children', () => {
it('should not render children section when no children', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory() },
})
expect(wrapper.find('.category-children').exists()).toBe(false)
})
it('should not render children section when children is empty', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory(), children: [] },
})
expect(wrapper.find('.category-children').exists()).toBe(false)
})
it('should render child links when children provided', () => {
const children = [
createMockCategory({ id: 2, name: 'Child 1', slug: 'child-1' }),
createMockCategory({ id: 3, name: 'Child 2', slug: 'child-2' }),
]
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory(), children },
global: {
stubs: {
'router-link': {
template: '<a class="child-link"><slot /></a>',
props: ['to'],
},
},
},
})
expect(wrapper.find('.category-children').exists()).toBe(true)
const links = wrapper.findAll('.child-link')
expect(links).toHaveLength(2)
expect(links[0]!.text()).toBe('Child 1')
expect(links[1]!.text()).toBe('Child 2')
})
it('should not render children when hideChildren is true', () => {
const children = [createMockCategory({ id: 2, name: 'Child 1', slug: 'child-1' })]
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory(), children, hideChildren: true },
})
expect(wrapper.find('.category-children').exists()).toBe(false)
})
})
describe('structure', () => {
it('should have correct class', () => {
const wrapper = mount(CategoryCard, {

View File

@@ -29,6 +29,18 @@
</div>
<p v-if="category.description" class="category-description">{{ category.description }}</p>
<p v-else class="category-description muted">{{ strings.noDescription }}</p>
<!-- Child category links -->
<div v-if="!hideChildren && visibleChildren.length > 0" class="category-children">
<router-link
v-for="child in visibleChildren"
:key="child.id"
:to="`/c/${child.slug}`"
class="child-link"
@click.stop
>
{{ child.name }}
</router-link>
</div>
</div>
</template>
@@ -48,6 +60,14 @@ export default defineComponent({
type: Boolean,
default: false,
},
children: {
type: Array as PropType<Category[]>,
default: () => [],
},
hideChildren: {
type: Boolean,
default: false,
},
},
computed: {
cardStyle(): Record<string, string> {
@@ -61,6 +81,9 @@ export default defineComponent({
}
return style
},
visibleChildren(): Category[] {
return this.children
},
},
data() {
return {
@@ -109,6 +132,16 @@ export default defineComponent({
.category-description.muted {
color: var(--card-text-muted);
}
.child-link {
background: rgba(255, 255, 255, 0.15);
color: var(--card-text);
border-color: rgba(255, 255, 255, 0.2);
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
}
&.unread:not(.colored) {
@@ -192,5 +225,35 @@ export default defineComponent({
font-style: italic;
}
}
.category-children {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--color-border);
}
.child-link {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
text-decoration: none;
background: var(--color-background-dark);
color: var(--color-main-text);
border: 1px solid var(--color-border);
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
&:hover {
background: var(--color-background-hover);
border-color: var(--color-primary-element);
}
}
}
</style>

View File

@@ -64,55 +64,60 @@
</div>
</div>
<!-- Category rows under this header -->
<div v-for="category in header.categories" :key="category.id" class="table-row">
<div class="col-category">
<span class="category-name">{{ category.name }}</span>
<span v-if="category.description" class="category-desc muted">
{{ category.description }}
</span>
</div>
<!-- Category rows under this header (including subcategories) -->
<template v-for="row in flattenCategories(header.categories || [])" :key="row.category.id">
<div class="table-row" :class="{ 'subcategory-row': row.depth > 0 }">
<div class="col-category" :style="{ paddingLeft: `${row.depth * 24 + 16}px` }">
<span class="category-name">
<span v-if="row.depth > 0" class="subcategory-arrow"></span>
{{ row.category.name }}
</span>
<span v-if="row.category.description" class="category-desc muted">
{{ row.category.description }}
</span>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canView || false"
:disabled="disableView"
@update:model-value="updateCategoryView(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[row.category.id]?.canView || false"
:disabled="disableView"
@update:model-value="updateCategoryView(row.category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canPost || false"
:disabled="disablePost"
@update:model-value="updateCategoryPost(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[row.category.id]?.canPost || false"
:disabled="disablePost"
@update:model-value="updateCategoryPost(row.category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canReply || false"
:disabled="disableReply"
@update:model-value="updateCategoryReply(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[row.category.id]?.canReply || false"
:disabled="disableReply"
@update:model-value="updateCategoryReply(row.category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canModerate || false"
:disabled="disableModerate"
@update:model-value="updateCategoryModerate(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[row.category.id]?.canModerate || false"
:disabled="disableModerate"
@update:model-value="updateCategoryModerate(row.category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
</div>
</div>
</template>
</template>
</div>
<div v-else class="muted">{{ strings.noCategories }}</div>
@@ -124,7 +129,12 @@ import { defineComponent, type PropType } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import { t } from '@nextcloud/l10n'
import type { CategoryHeader } from '@/types'
import type { Category, CategoryHeader } from '@/types'
interface FlatCategoryRow {
category: Category
depth: number
}
export interface CategoryPermission {
canView: boolean
@@ -205,6 +215,24 @@ export default defineComponent({
}
},
methods: {
flattenCategories(categories: Category[], depth = 0): FlatCategoryRow[] {
const result: FlatCategoryRow[] = []
for (const cat of categories) {
result.push({ category: cat, depth })
if (cat.children && cat.children.length > 0) {
result.push(...this.flattenCategories(cat.children, depth + 1))
}
}
return result
},
/** Get all categories under a header (including nested subcategories) */
getAllCategoriesInHeader(headerId: number): Category[] {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories) return []
return this.flattenCategories(header.categories).map((row) => row.category)
},
ensurePermission(categoryId: number): CategoryPermission {
if (!this.permissions[categoryId]) {
this.permissions[categoryId] = {
@@ -221,13 +249,13 @@ export default defineComponent({
headerId: number,
key: keyof CategoryPermission,
): { checked: boolean; indeterminate: boolean } {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories || header.categories.length === 0) {
const allCats = this.getAllCategoriesInHeader(headerId)
if (allCats.length === 0) {
return { checked: false, indeterminate: false }
}
const checkedCount = header.categories.filter((cat) => this.permissions[cat.id]?.[key]).length
const totalCount = header.categories.length
const checkedCount = allCats.filter((cat) => this.permissions[cat.id]?.[key]).length
const totalCount = allCats.length
if (checkedCount === 0) {
return { checked: false, indeterminate: false }
@@ -265,11 +293,11 @@ export default defineComponent({
},
toggleHeader(headerId: number, key: keyof CategoryPermission): void {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories) return
const allCats = this.getAllCategoriesInHeader(headerId)
if (allCats.length === 0) return
const newValue = !this.getHeaderState(headerId, key).checked
header.categories.forEach((cat) => {
allCats.forEach((cat) => {
this.ensurePermission(cat.id)[key] = newValue
})
this.$emit('update:permissions', this.permissions)
@@ -366,6 +394,15 @@ export default defineComponent({
background: var(--color-background-hover);
}
&.subcategory-row {
background: var(--color-background-dark);
}
.subcategory-arrow {
color: var(--color-text-maxcontrast);
margin-right: 4px;
}
.col-category {
display: flex;
flex-direction: column;

View File

@@ -211,7 +211,7 @@ describe('MoveCategoryDialog', () => {
}
const categoryOption = vm.categoryOptions.find((o) => !o.isHeader)
expect(categoryOption!.name).toBe(' Category') // Two spaces prefix
expect(categoryOption!.name).toBe('\u00A0\u00A0Category') // Non-breaking space prefix
})
it('excludes headers with no categories', async () => {

View File

@@ -84,6 +84,7 @@ interface CategoryOption {
id: number
name: string
isHeader?: boolean
$isDisabled?: boolean
}
export default defineComponent({
@@ -147,16 +148,11 @@ export default defineComponent({
id: -header.id, // Negative ID to distinguish from categories
name: header.name,
isHeader: true,
$isDisabled: true,
})
// Add categories under this header
for (const category of header.categories) {
options.push({
id: category.id,
name: ` ${category.name}`,
isHeader: false,
})
}
// Add categories under this header (recursively)
this.addCategoryOptions(header.categories, options, 1)
}
}
@@ -176,6 +172,20 @@ export default defineComponent({
},
},
methods: {
addCategoryOptions(categories: Category[], options: CategoryOption[], depth: number): void {
const indent = '\u00A0\u00A0'.repeat(depth)
for (const category of categories) {
options.push({
id: category.id,
name: `${indent}${category.name}`,
isHeader: false,
})
if (category.children && category.children.length > 0) {
this.addCategoryOptions(category.children, options, depth + 1)
}
}
},
async loadCategories() {
try {
this.loading = true

View File

@@ -0,0 +1,332 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createMockCategory } from '@/test-mocks'
import type { CategoryHeader } from '@/types'
// Mock the axios module before importing the composable
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
}))
import { useCategories } from '../useCategories'
import { ocs } from '@/axios'
describe('useCategories', () => {
beforeEach(() => {
const { clear } = useCategories()
clear()
vi.clearAllMocks()
})
describe('tree building', () => {
it('should build tree from flat categories', async () => {
const parent = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Parent' })
const child1 = createMockCategory({
id: 2,
headerId: null,
parentId: 1,
name: 'Child 1',
sortOrder: 0,
})
const child2 = createMockCategory({
id: 3,
headerId: null,
parentId: 1,
name: 'Child 2',
sortOrder: 1,
})
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [parent, child1, child2],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, categoryHeaders } = useCategories()
await fetchCategories(true)
// Top-level should only have parent
expect(categoryHeaders.value).toHaveLength(1)
expect(categoryHeaders.value[0]!.categories).toHaveLength(1)
expect(categoryHeaders.value[0]!.categories![0]!.name).toBe('Parent')
// Parent should have children
const parentCat = categoryHeaders.value[0]!.categories![0]!
expect(parentCat.children).toHaveLength(2)
expect(parentCat.children![0]!.name).toBe('Child 1')
expect(parentCat.children![1]!.name).toBe('Child 2')
})
it('should sort children by sortOrder', async () => {
const parent = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Parent' })
const child1 = createMockCategory({
id: 2,
headerId: null,
parentId: 1,
name: 'Second',
sortOrder: 2,
})
const child2 = createMockCategory({
id: 3,
headerId: null,
parentId: 1,
name: 'First',
sortOrder: 1,
})
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [parent, child1, child2],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, categoryHeaders } = useCategories()
await fetchCategories(true)
const parentCat = categoryHeaders.value[0]!.categories![0]!
expect(parentCat.children![0]!.name).toBe('First')
expect(parentCat.children![1]!.name).toBe('Second')
})
it('should handle categories with no children', async () => {
const cat = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Standalone' })
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [cat],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, categoryHeaders } = useCategories()
await fetchCategories(true)
expect(categoryHeaders.value[0]!.categories![0]!.children).toEqual([])
})
it('should build a 3-level deep tree (grandchildren)', async () => {
const grandparent = createMockCategory({
id: 1,
headerId: 1,
parentId: null,
name: 'Grandparent',
})
const parent = createMockCategory({
id: 2,
headerId: null,
parentId: 1,
name: 'Parent',
})
const child = createMockCategory({
id: 3,
headerId: null,
parentId: 2,
name: 'Child',
})
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [grandparent, parent, child],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, categoryHeaders } = useCategories()
await fetchCategories(true)
// Only grandparent at top level
expect(categoryHeaders.value[0]!.categories).toHaveLength(1)
const gp = categoryHeaders.value[0]!.categories![0]!
expect(gp.name).toBe('Grandparent')
// Parent nested under grandparent
expect(gp.children).toHaveLength(1)
expect(gp.children![0]!.name).toBe('Parent')
// Child nested under parent
expect(gp.children![0]!.children).toHaveLength(1)
expect(gp.children![0]!.children![0]!.name).toBe('Child')
})
})
describe('getAllCategoriesFlat', () => {
it('should return all categories including children', async () => {
const parent = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Parent' })
const child = createMockCategory({ id: 2, headerId: null, parentId: 1, name: 'Child' })
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [parent, child],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, getAllCategoriesFlat } = useCategories()
await fetchCategories(true)
const flat = getAllCategoriesFlat()
expect(flat).toHaveLength(2)
expect(flat.map((c) => c.name)).toContain('Parent')
expect(flat.map((c) => c.name)).toContain('Child')
})
it('should include deeply nested categories', async () => {
const gp = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'GP' })
const p = createMockCategory({ id: 2, headerId: null, parentId: 1, name: 'P' })
const c = createMockCategory({ id: 3, headerId: null, parentId: 2, name: 'C' })
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [gp, p, c],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, getAllCategoriesFlat } = useCategories()
await fetchCategories(true)
const flat = getAllCategoriesFlat()
expect(flat).toHaveLength(3)
expect(flat.map((cat) => cat.name)).toEqual(['GP', 'P', 'C'])
})
})
describe('findCategoryInTree', () => {
it('should find a child category by ID', async () => {
const parent = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Parent' })
const child = createMockCategory({ id: 2, headerId: null, parentId: 1, name: 'Child' })
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [parent, child],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, findCategoryInTree } = useCategories()
await fetchCategories(true)
const found = findCategoryInTree(2)
expect(found).not.toBeNull()
expect(found!.name).toBe('Child')
})
it('should return null for nonexistent ID', async () => {
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, findCategoryInTree } = useCategories()
await fetchCategories(true)
expect(findCategoryInTree(999)).toBeNull()
})
})
describe('markCategoryAsRead', () => {
it('should mark a child category as read', async () => {
const parent = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Parent' })
const child = createMockCategory({
id: 2,
headerId: null,
parentId: 1,
name: 'Child',
readAt: null,
})
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [parent, child],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, markCategoryAsRead, findCategoryInTree } = useCategories()
await fetchCategories(true)
markCategoryAsRead(2)
const found = findCategoryInTree(2)
expect(found!.readAt).toBeDefined()
expect(found!.readAt).toBeGreaterThan(0)
})
})
})

View File

@@ -1,6 +1,6 @@
import { ref, type Ref } from 'vue'
import { ocs } from '@/axios'
import type { CategoryHeader } from '@/types'
import type { Category, CategoryHeader } from '@/types'
// Shared state - will persist across components
// The API returns an array of headers, each with a nested 'categories' array
@@ -9,6 +9,55 @@ const loading = ref<boolean>(false)
const error = ref<string | null>(null)
const loaded = ref<boolean>(false)
/**
* Build a category tree from flat category list.
* Moves child categories out of the top-level header.categories
* and nests them under their parent's children array.
*/
function buildCategoryTree(headers: CategoryHeader[]): CategoryHeader[] {
// Collect all categories into a flat map
const allCategories: Category[] = []
for (const header of headers) {
if (header.categories) {
for (const cat of header.categories) {
allCategories.push(cat)
}
}
}
const catMap = new Map<number, Category>()
for (const cat of allCategories) {
cat.children = []
catMap.set(cat.id, cat)
}
// Attach children to parents
const topLevelIds = new Set<number>()
for (const cat of allCategories) {
if (cat.parentId !== null && catMap.has(cat.parentId)) {
catMap.get(cat.parentId)!.children!.push(cat)
} else {
topLevelIds.add(cat.id)
}
}
// Sort children by sortOrder
for (const cat of allCategories) {
if (cat.children && cat.children.length > 1) {
cat.children.sort((a, b) => a.sortOrder - b.sortOrder)
}
}
// Rebuild header.categories to only contain top-level categories
for (const header of headers) {
if (header.categories) {
header.categories = header.categories.filter((cat) => topLevelIds.has(cat.id))
}
}
return headers
}
/**
* Composable for managing categories
* Provides shared state across components to avoid redundant API calls
@@ -33,7 +82,7 @@ export function useCategories() {
error.value = null
const response = await ocs.get<CategoryHeader[]>('/categories')
categoryHeaders.value = response.data || []
categoryHeaders.value = buildCategoryTree(response.data || [])
loaded.value = true
return categoryHeaders.value
@@ -61,15 +110,34 @@ export function useCategories() {
* Updates the readAt timestamp so the category appears read without refetching
*/
const markCategoryAsRead = (categoryId: number): void => {
const cat = findCategoryInTree(categoryId)
if (cat) {
cat.readAt = Math.floor(Date.now() / 1000)
}
}
/**
* Find a category by ID in the tree (searches recursively)
*/
const findCategoryInTree = (categoryId: number): Category | null => {
for (const header of categoryHeaders.value) {
if (!header.categories) continue
for (const category of header.categories) {
if (category.id === categoryId) {
category.readAt = Math.floor(Date.now() / 1000)
return
}
}
const found = findInChildren(header.categories, categoryId)
if (found) return found
}
return null
}
/**
* Get a flat list of all categories across all headers (includes children)
*/
const getAllCategoriesFlat = (): Category[] => {
const result: Category[] = []
for (const header of categoryHeaders.value) {
if (!header.categories) continue
collectFlat(header.categories, result)
}
return result
}
/**
@@ -93,5 +161,29 @@ export function useCategories() {
refresh,
clear,
markCategoryAsRead,
findCategoryInTree,
getAllCategoriesFlat,
}
}
/** Recursively search for a category by ID */
function findInChildren(categories: Category[], id: number): Category | null {
for (const cat of categories) {
if (cat.id === id) return cat
if (cat.children) {
const found = findInChildren(cat.children, id)
if (found) return found
}
}
return null
}
/** Recursively collect all categories into a flat array */
function collectFlat(categories: Category[], result: Category[]): void {
for (const cat of categories) {
result.push(cat)
if (cat.children) {
collectFlat(cat.children, result)
}
}
}

View File

@@ -121,10 +121,14 @@ export function createMockCategory(overrides: Partial<Category> = {}): Category
return {
id: 1,
headerId: 1,
parentId: null,
name: 'Test Category',
description: 'Test description',
slug: 'test-category',
sortOrder: 0,
color: null,
textColor: null,
hideChildrenOnCard: false,
threadCount: 10,
postCount: 50,
createdAt: Date.now(),

View File

@@ -5,19 +5,22 @@
export interface Category {
id: number
headerId: number
headerId: number | null
parentId: number | null
name: string
description: string | null
slug: string
sortOrder: number
color: string | null
textColor: 'light' | 'dark' | null
hideChildrenOnCard: boolean
threadCount: number
postCount: number
createdAt: number
updatedAt: number
lastActivityAt?: number | null
readAt?: number | null
children?: Category[]
}
export interface CategoryHeader {

View File

@@ -45,6 +45,8 @@
v-for="category in header.categories"
:key="category.id"
:category="category"
:children="category.children || []"
:hide-children="category.hideChildrenOnCard"
:is-unread="isCategoryUnread(category)"
@click="navigateToCategory(category)"
/>

View File

@@ -7,7 +7,7 @@
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
{{ backLabel }}
</NcButton>
</template>
@@ -43,6 +43,24 @@
class="mt-16"
/>
<!-- Subcategories section -->
<div
v-if="!loading && !error && childCategories.length > 0"
class="subcategories-section mt-16"
>
<h3 class="subcategories-title">{{ strings.subcategories }}</h3>
<div class="subcategories-grid">
<CategoryCard
v-for="child in childCategories"
:key="child.id"
:category="child"
:children="child.children || []"
:is-unread="isCategoryUnread(child)"
@click="navigateToChildCategory(child)"
/>
</div>
</div>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
@@ -141,6 +159,7 @@ import CategoryNotFound from '@/views/CategoryNotFound.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
import MessagePlusIcon from '@icons/MessagePlus.vue'
import CategoryCard from '@/components/CategoryCard'
import type { Category, Thread } from '@/types'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
@@ -153,9 +172,14 @@ export default defineComponent({
name: 'CategoryView',
setup() {
const { userId } = useCurrentUser()
const { markCategoryAsRead } = useCategories()
const { markCategoryAsRead, findCategoryInTree } = useCategories()
const { checkCategoryPermission } = usePermissions()
return { userId, markCategoryAsReadLocal: markCategoryAsRead, checkCategoryPermission }
return {
userId,
markCategoryAsReadLocal: markCategoryAsRead,
findCategoryInTree,
checkCategoryPermission,
}
},
components: {
NcButton,
@@ -167,6 +191,7 @@ export default defineComponent({
ThreadCard,
Pagination,
CategoryNotFound,
CategoryCard,
ArrowLeftIcon,
RefreshIcon,
MessagePlusIcon,
@@ -193,6 +218,7 @@ export default defineComponent({
emptyTitle: t('forum', 'No threads yet'),
emptyDesc: t('forum', 'Be the first to start a discussion in this category.'),
retry: t('forum', 'Retry'),
subcategories: t('forum', 'Subcategories'),
},
}
},
@@ -203,9 +229,24 @@ export default defineComponent({
categorySlug(): string | null {
return (this.$route.params.slug as string) || null
},
backLabel(): string {
if (this.category?.parentId) {
const parent = this.findCategoryInTree(this.category.parentId)
if (parent) {
return t('forum', 'Back to {name}', { name: parent.name })
}
}
return t('forum', 'Back to categories')
},
sortedThreads(): Thread[] {
return this.threads
},
childCategories(): Category[] {
if (!this.category) return []
// Look up this category in the tree to get its children
const catInTree = this.findCategoryInTree(this.category.id)
return catInTree?.children || []
},
},
created() {
this.refresh()
@@ -389,8 +430,27 @@ export default defineComponent({
}
},
navigateToChildCategory(child: Category): void {
this.$router.push(`/c/${child.slug}`)
},
isCategoryUnread(category: Category): boolean {
if (this.userId === null) return false
const lastActivity = category.lastActivityAt
if (!lastActivity) return false
if (category.readAt == null) return true
return lastActivity > category.readAt
},
goBack(): void {
// Always navigate to home, not browser history
// Navigate to parent category if this is a child, otherwise home
if (this.category?.parentId) {
const parent = this.findCategoryInTree(this.category.parentId)
if (parent) {
this.$router.push(`/c/${parent.slug}`)
return
}
}
this.$router.push('/')
},
},
@@ -399,6 +459,21 @@ export default defineComponent({
<style scoped lang="scss">
.category-view {
.subcategories-section {
.subcategories-title {
margin: 0 0 12px 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-main-text);
}
.subcategories-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
}
.threads-list {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,391 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockCategory, createMockRole } from '@/test-mocks'
import type { Category, CategoryHeader, Role } from '@/types'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
},
}))
// Reactive state for categories
const mockCategoryHeaders = ref<CategoryHeader[]>([])
const mockFetchCategories = vi.fn().mockResolvedValue([])
const mockRefresh = vi.fn().mockResolvedValue([])
const mockGetAllFlat = vi.fn().mockReturnValue([])
vi.mock('@/composables/useCategories', () => ({
useCategories: () => ({
categoryHeaders: mockCategoryHeaders,
fetchCategories: mockFetchCategories,
refresh: mockRefresh,
getAllCategoriesFlat: mockGetAllFlat,
}),
}))
// Mock NcCheckboxRadioSwitch (imports .css that Vitest can't handle)
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
default: {
name: 'NcCheckboxRadioSwitch',
template: '<label class="nc-checkbox"><input type="checkbox" /><slot /></label>',
props: ['modelValue', 'disabled', 'value', 'type', 'name'],
emits: ['update:model-value'],
},
}))
// Mock icons
vi.mock('@icons/ArrowLeft.vue', () => createIconMock('ArrowLeftIcon'))
// Mock components
vi.mock('@/components/PageWrapper', () =>
createComponentMock('PageWrapper', {
template: '<div class="page-wrapper-mock"><slot name="toolbar" /><slot /></div>',
}),
)
vi.mock('@/components/AppToolbar', () =>
createComponentMock('AppToolbar', {
template: '<div class="app-toolbar-mock"><slot name="left" /></div>',
}),
)
vi.mock('@/components/PageHeader', () =>
createComponentMock('PageHeader', {
template: '<div class="page-header-mock" />',
props: ['title', 'subtitle'],
}),
)
vi.mock('@/components/FormSection', () =>
createComponentMock('FormSection', {
template: '<div class="form-section-mock"><slot /></div>',
props: ['title', 'subtitle'],
}),
)
vi.mock('@/components/CategoryCard', () =>
createComponentMock('CategoryCard', {
template: '<div class="category-card-mock" />',
props: ['category'],
}),
)
vi.mock('@/components/ColorPickerPreset', () =>
createComponentMock('ColorPickerPreset', {
template: '<div class="color-picker-mock" />',
props: ['modelValue', 'presets', 'label'],
emits: ['update:modelValue'],
}),
)
import AdminCategoryEdit from '../admin/AdminCategoryEdit.vue'
import { ocs } from '@/axios'
const mockOcsGet = vi.mocked(ocs.get)
const mockOcsPost = vi.mocked(ocs.post)
const mockOcsPut = vi.mocked(ocs.put)
function createHeader(id: number, name: string, categories: Category[] = []): CategoryHeader {
return { id, name, description: null, sortOrder: 0, createdAt: Date.now(), categories }
}
describe('AdminCategoryEdit', () => {
const mockRouter = { push: vi.fn() }
const defaultRoles: Role[] = [
createMockRole({ id: 1, name: 'Admin', roleType: 'admin', isSystemRole: true }),
createMockRole({ id: 2, name: 'Moderator', roleType: 'moderator', isSystemRole: true }),
createMockRole({ id: 3, name: 'Member', roleType: 'default', isSystemRole: true }),
createMockRole({ id: 4, name: 'Guest', roleType: 'guest', isSystemRole: true }),
]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockResponse = (data: unknown): Promise<any> => Promise.resolve({ data })
beforeEach(() => {
vi.clearAllMocks()
mockCategoryHeaders.value = []
mockFetchCategories.mockResolvedValue([])
mockGetAllFlat.mockReturnValue([])
})
const createWrapper = (routeParams: Record<string, string> = {}) =>
mount(AdminCategoryEdit, {
global: {
mocks: {
$router: mockRouter,
$route: { params: routeParams, path: '/admin/categories/create' },
},
},
})
const setupCreateMocks = () => {
mockOcsGet.mockImplementation((url: string) => {
if (url === '/roles') return mockResponse(defaultRoles)
if (url === '/teams') return mockResponse([])
return mockResponse(null)
})
}
const setupEditMocks = (category: Category) => {
mockOcsGet.mockImplementation((url: string) => {
if (url === '/roles') return mockResponse(defaultRoles)
if (url === '/teams') return mockResponse([])
if (url === `/categories/${category.id}`) return mockResponse(category)
if (url === `/categories/${category.id}/permissions`) return mockResponse([])
return mockResponse(null)
})
}
describe('parent dropdown', () => {
it('should include headers as parent options', async () => {
mockCategoryHeaders.value = [createHeader(1, 'General'), createHeader(2, 'Support')]
setupCreateMocks()
const wrapper = createWrapper()
await flushPromises()
type VM = { parentOptions: Array<{ id: string; label: string; type: string }> }
const vm = wrapper.vm as unknown as VM
const headerOptions = vm.parentOptions.filter((o) => o.type === 'header')
expect(headerOptions).toHaveLength(2)
expect(headerOptions[0]!.label).toBe('General')
expect(headerOptions[1]!.label).toBe('Support')
})
it('should include categories nested under headers', async () => {
const cat = createMockCategory({ id: 10, name: 'Announcements', slug: 'ann' })
mockCategoryHeaders.value = [createHeader(1, 'General', [cat])]
setupCreateMocks()
const wrapper = createWrapper()
await flushPromises()
type VM = { parentOptions: Array<{ id: string; label: string; type: string }> }
const vm = wrapper.vm as unknown as VM
const catOptions = vm.parentOptions.filter((o) => o.type === 'category')
expect(catOptions).toHaveLength(1)
expect(catOptions[0]!.id).toBe('category:10')
})
it('should exclude the current category and its descendants when editing', async () => {
const grandchild = createMockCategory({
id: 3,
name: 'GC',
parentId: 2,
slug: 'gc',
children: [],
})
const child = createMockCategory({
id: 2,
name: 'Child',
parentId: 1,
slug: 'child',
children: [grandchild],
})
const parent = createMockCategory({
id: 1,
name: 'Parent',
slug: 'parent',
children: [child],
})
const sibling = createMockCategory({
id: 4,
name: 'Sibling',
slug: 'sibling',
children: [],
})
mockCategoryHeaders.value = [createHeader(1, 'H', [parent, sibling])]
// getAllCategoriesFlat returns the flat list for descendant collection
mockGetAllFlat.mockReturnValue([parent, child, grandchild, sibling])
const editCategory = createMockCategory({
id: 1,
headerId: 1,
name: 'Parent',
slug: 'parent',
})
setupEditMocks(editCategory)
const wrapper = createWrapper({ id: '1' })
await flushPromises()
type VM = { parentOptions: Array<{ id: string; label: string; type: string }> }
const vm = wrapper.vm as unknown as VM
const catOptions = vm.parentOptions.filter((o) => o.type === 'category')
const catIds = catOptions.map((o) => o.id)
// Should exclude category 1 (self), 2 (child), 3 (grandchild)
expect(catIds).not.toContain('category:1')
expect(catIds).not.toContain('category:2')
expect(catIds).not.toContain('category:3')
// Should include sibling
expect(catIds).toContain('category:4')
})
})
describe('form submission', () => {
it('should send parentId when a category parent is selected', async () => {
const cat = createMockCategory({ id: 10, name: 'Parent Cat', slug: 'parent-cat' })
mockCategoryHeaders.value = [createHeader(1, 'H', [cat])]
setupCreateMocks()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPost.mockResolvedValue({ data: { id: 99 } } as any)
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedParent: { id: string; label: string; type: string } | null
formData: {
name: string
slug: string
parentId: number | null
headerId: number | null
}
submitForm: () => Promise<void>
}
vm.selectedParent = { id: 'category:10', label: 'Parent Cat', type: 'category' }
vm.formData.parentId = 10
vm.formData.headerId = null
vm.formData.name = 'New Child'
vm.formData.slug = 'new-child'
await wrapper.vm.$nextTick()
await vm.submitForm()
await flushPromises()
expect(mockOcsPost).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({
parentId: 10,
headerId: null,
name: 'New Child',
slug: 'new-child',
}),
)
})
it('should send headerId when a header parent is selected', async () => {
mockCategoryHeaders.value = [createHeader(1, 'General')]
setupCreateMocks()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPost.mockResolvedValue({ data: { id: 99 } } as any)
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedParent: { id: string; label: string; type: string } | null
formData: {
name: string
slug: string
parentId: number | null
headerId: number | null
}
submitForm: () => Promise<void>
}
vm.selectedParent = { id: 'header:1', label: 'General', type: 'header' }
vm.formData.headerId = 1
vm.formData.parentId = null
vm.formData.name = 'New Category'
vm.formData.slug = 'new-category'
await wrapper.vm.$nextTick()
await vm.submitForm()
await flushPromises()
expect(mockOcsPost).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({
headerId: 1,
parentId: null,
name: 'New Category',
}),
)
})
it('should send hideChildrenOnCard in the payload', async () => {
mockCategoryHeaders.value = [createHeader(1, 'H')]
setupCreateMocks()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPost.mockResolvedValue({ data: { id: 99 } } as any)
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedParent: { id: string; label: string; type: string } | null
formData: {
name: string
slug: string
headerId: number | null
parentId: number | null
hideChildrenOnCard: boolean
}
submitForm: () => Promise<void>
}
vm.selectedParent = { id: 'header:1', label: 'H', type: 'header' }
vm.formData.headerId = 1
vm.formData.parentId = null
vm.formData.name = 'Test'
vm.formData.slug = 'test'
vm.formData.hideChildrenOnCard = true
await wrapper.vm.$nextTick()
await vm.submitForm()
await flushPromises()
expect(mockOcsPost).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({ hideChildrenOnCard: true }),
)
})
it('should use PUT when editing an existing category', async () => {
const existingCat = createMockCategory({
id: 5,
headerId: 1,
name: 'Existing',
slug: 'existing',
})
mockCategoryHeaders.value = [createHeader(1, 'H', [existingCat])]
setupEditMocks(existingCat)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPut.mockResolvedValue({ data: existingCat } as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPost.mockResolvedValue({ data: { success: true } } as any)
const wrapper = createWrapper({ id: '5' })
await flushPromises()
const vm = wrapper.vm as unknown as { submitForm: () => Promise<void> }
await vm.submitForm()
await flushPromises()
expect(mockOcsPut).toHaveBeenCalledWith('/categories/5', expect.any(Object))
})
})
describe('navigation', () => {
it('should navigate back to category list on cancel', async () => {
mockCategoryHeaders.value = []
setupCreateMocks()
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as { goBack: () => void }
vm.goBack()
expect(mockRouter.push).toHaveBeenCalledWith('/admin/categories')
})
})
})

View File

@@ -0,0 +1,332 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockCategory } from '@/test-mocks'
import type { Category, CategoryHeader } from '@/types'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}))
// Reactive state for categoryHeaders so tests can control it
const mockCategoryHeaders = ref<CategoryHeader[]>([])
const mockRefresh = vi.fn().mockResolvedValue([])
vi.mock('@/composables/useCategories', () => ({
useCategories: () => ({
categoryHeaders: mockCategoryHeaders,
loading: computed(() => false),
error: computed(() => null),
refresh: mockRefresh,
}),
}))
// Mock NcCheckboxRadioSwitch (imports .css that Vitest can't handle)
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
default: {
name: 'NcCheckboxRadioSwitch',
template: '<label class="nc-checkbox"><input type="checkbox" /><slot /></label>',
props: ['modelValue', 'disabled', 'value', 'type', 'name'],
emits: ['update:model-value'],
},
}))
// Mock icons
vi.mock('@icons/Plus.vue', () => createIconMock('PlusIcon'))
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
vi.mock('@icons/ChevronUp.vue', () => createIconMock('ChevronUpIcon'))
vi.mock('@icons/ChevronDown.vue', () => createIconMock('ChevronDownIcon'))
vi.mock('@icons/Information.vue', () => createIconMock('InformationIcon'))
// Mock components
vi.mock('@/components/PageWrapper', () =>
createComponentMock('PageWrapper', {
template: '<div class="page-wrapper-mock"><slot name="toolbar" /><slot /></div>',
}),
)
vi.mock('@/components/PageHeader', () =>
createComponentMock('PageHeader', {
template: '<div class="page-header-mock" />',
props: ['title', 'subtitle'],
}),
)
vi.mock('@/components/AppToolbar', () =>
createComponentMock('AppToolbar', {
template: '<div class="app-toolbar-mock"><slot name="right" /></div>',
}),
)
vi.mock('@/components/HeaderEditDialog', () =>
createComponentMock('HeaderEditDialog', {
template: '<div class="header-edit-dialog-mock" />',
props: ['open', 'headerId', 'name', 'description', 'sortOrder'],
emits: ['update:open', 'saved'],
}),
)
import AdminCategoryList from '../admin/AdminCategoryList.vue'
import { ocs } from '@/axios'
const mockOcsPost = vi.mocked(ocs.post)
function createHeader(id: number, name: string, categories: Category[] = []): CategoryHeader {
return {
id,
name,
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories,
}
}
describe('AdminCategoryList', () => {
const mockRouter = { push: vi.fn() }
beforeEach(() => {
vi.clearAllMocks()
mockCategoryHeaders.value = []
})
const createWrapper = () =>
mount(AdminCategoryList, {
global: { mocks: { $router: mockRouter, $route: { path: '/admin/categories' } } },
})
describe('rendering categories', () => {
it('should render top-level categories', async () => {
const cat = createMockCategory({ id: 1, name: 'General', slug: 'general' })
mockCategoryHeaders.value = [createHeader(1, 'Main', [cat])]
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('General')
expect(wrapper.text()).toContain('general')
})
it('should render subcategories with indentation', async () => {
const child = createMockCategory({
id: 2,
name: 'Sub Category',
parentId: 1,
slug: 'sub',
})
const parent = createMockCategory({
id: 1,
name: 'Parent',
slug: 'parent',
children: [child],
})
mockCategoryHeaders.value = [createHeader(1, 'Main', [parent])]
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('Parent')
expect(wrapper.text()).toContain('Sub Category')
// Subcategory row should have deeper indentation
const rows = wrapper.findAll('.category-row')
expect(rows.length).toBeGreaterThanOrEqual(2)
const subRow = rows.find((r) => r.text().includes('Sub Category'))
expect(subRow).toBeDefined()
expect(subRow!.classes()).toContain('subcategory-row')
})
it('should render grandchildren (3 levels)', async () => {
const grandchild = createMockCategory({
id: 3,
name: 'Grandchild',
parentId: 2,
slug: 'grandchild',
children: [],
})
const child = createMockCategory({
id: 2,
name: 'Child',
parentId: 1,
slug: 'child',
children: [grandchild],
})
const parent = createMockCategory({
id: 1,
name: 'Parent',
slug: 'parent',
children: [child],
})
mockCategoryHeaders.value = [createHeader(1, 'Main', [parent])]
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('Parent')
expect(wrapper.text()).toContain('Child')
expect(wrapper.text()).toContain('Grandchild')
})
})
describe('flattenCategoriesWithContext', () => {
it('should flatten tree with correct depth info', async () => {
const grandchild = createMockCategory({
id: 3,
name: 'GC',
parentId: 2,
slug: 'gc',
children: [],
})
const child = createMockCategory({
id: 2,
name: 'C',
parentId: 1,
slug: 'c',
children: [grandchild],
})
const parent = createMockCategory({
id: 1,
name: 'P',
slug: 'p',
children: [child],
})
mockCategoryHeaders.value = [createHeader(1, 'H', [parent])]
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
flattenCategoriesWithContext: (
cats: Category[],
headerId: number,
) => Array<{ category: Category; depth: number; index: number; siblings: Category[] }>
}
const rows = vm.flattenCategoriesWithContext([parent], 1)
expect(rows).toHaveLength(3)
expect(rows[0]!.category.name).toBe('P')
expect(rows[0]!.depth).toBe(0)
expect(rows[1]!.category.name).toBe('C')
expect(rows[1]!.depth).toBe(1)
expect(rows[2]!.category.name).toBe('GC')
expect(rows[2]!.depth).toBe(2)
})
it('should provide correct sibling references', async () => {
const child1 = createMockCategory({
id: 2,
name: 'C1',
parentId: 1,
slug: 'c1',
children: [],
})
const child2 = createMockCategory({
id: 3,
name: 'C2',
parentId: 1,
slug: 'c2',
children: [],
})
const parent = createMockCategory({
id: 1,
name: 'P',
slug: 'p',
children: [child1, child2],
})
mockCategoryHeaders.value = [createHeader(1, 'H', [parent])]
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
flattenCategoriesWithContext: (
cats: Category[],
headerId: number,
) => Array<{ category: Category; depth: number; index: number; siblings: Category[] }>
}
const rows = vm.flattenCategoriesWithContext([parent], 1)
// Parent row: siblings is the top-level array
expect(rows[0]!.siblings).toHaveLength(1)
// Child rows: siblings is parent.children
expect(rows[1]!.siblings).toHaveLength(2)
expect(rows[1]!.index).toBe(0)
expect(rows[2]!.siblings).toHaveLength(2)
expect(rows[2]!.index).toBe(1)
})
})
describe('reorder', () => {
it('should call reorder API when sorting siblings', async () => {
const child1 = createMockCategory({
id: 2,
name: 'C1',
parentId: 1,
slug: 'c1',
children: [],
})
const child2 = createMockCategory({
id: 3,
name: 'C2',
parentId: 1,
slug: 'c2',
children: [],
})
const parent = createMockCategory({
id: 1,
name: 'P',
slug: 'p',
children: [child1, child2],
})
mockCategoryHeaders.value = [createHeader(1, 'H', [parent])]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPost.mockResolvedValue({ data: { success: true } } as any)
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
reorderSiblings: (siblings: Category[], index: number, amount: number) => Promise<void>
}
await vm.reorderSiblings(parent.children!, 0, 1)
expect(mockOcsPost).toHaveBeenCalledWith('/categories/reorder', {
categories: expect.arrayContaining([
expect.objectContaining({ id: 3, sortOrder: 0 }),
expect.objectContaining({ id: 2, sortOrder: 1 }),
]),
})
})
})
describe('navigation', () => {
it('should navigate to edit page when clicking edit', async () => {
const cat = createMockCategory({ id: 5, name: 'Test', slug: 'test' })
mockCategoryHeaders.value = [createHeader(1, 'H', [cat])]
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as { editCategory: (id: number) => void }
vm.editCategory(5)
expect(mockRouter.push).toHaveBeenCalledWith('/admin/categories/5/edit')
})
it('should navigate to create page', async () => {
mockCategoryHeaders.value = [createHeader(1, 'H', [])]
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as { createCategory: () => void }
vm.createCategory()
expect(mockRouter.push).toHaveBeenCalledWith('/admin/categories/create')
})
})
})

View File

@@ -24,6 +24,7 @@ vi.mock('@/composables/useCurrentUser', () => ({
vi.mock('@/composables/useCategories', () => ({
useCategories: () => ({
markCategoryAsRead: vi.fn(),
findCategoryInTree: vi.fn().mockReturnValue(null),
}),
}))
@@ -82,6 +83,14 @@ vi.mock('@/views/CategoryNotFound.vue', () =>
}),
)
vi.mock('@/components/CategoryCard', () =>
createComponentMock('CategoryCard', {
template: '<div class="category-card-mock">{{ category.name }}</div>',
props: ['category', 'children', 'hideChildren', 'isUnread'],
emits: ['click'],
}),
)
import CategoryView from '../CategoryView.vue'
import { ocs } from '@/axios'

View File

@@ -42,28 +42,16 @@
<FormSection :title="strings.basicInfo">
<div class="form-grid">
<div class="form-group">
<label>{{ strings.categoryHeader }} *</label>
<label>{{ strings.parent }} *</label>
<div class="header-select-row">
<NcSelect
v-model="selectedHeader"
:options="headerOptions"
:placeholder="strings.selectHeader"
v-model="selectedParent"
:options="parentOptions"
:placeholder="strings.selectParent"
label="label"
track-by="id"
class="header-select"
/>
<NcButton @click="createNewHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.newHeader }}
</NcButton>
<NcButton v-if="selectedHeader" @click="editHeader">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.editHeader }}
</NcButton>
</div>
</div>
@@ -140,6 +128,12 @@
</NcCheckboxRadioSwitch>
</div>
</div>
<div class="hide-children-group">
<NcCheckboxRadioSwitch v-model="formData.hideChildrenOnCard">
{{ strings.hideChildrenOnCard }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.hideChildrenOnCardHelp }}</p>
</div>
</div>
<div class="design-preview">
<label>{{ strings.preview }}</label>
@@ -224,17 +218,6 @@
</NcButton>
</div>
</div>
<!-- Header Edit/Create Dialog -->
<HeaderEditDialog
:open="headerDialog.show"
:header-id="headerDialog.id"
:name="headerDialog.name"
:description="headerDialog.description"
:sort-order="headerDialog.sortOrder"
@update:open="headerDialog.show = $event"
@saved="handleHeaderSaved"
/>
</div>
</PageWrapper>
</template>
@@ -246,32 +229,29 @@ import AppToolbar from '@/components/AppToolbar'
import FormSection from '@/components/FormSection'
import CategoryCard from '@/components/CategoryCard'
import ColorPickerPreset from '@/components/ColorPickerPreset'
import HeaderEditDialog from '@/components/HeaderEditDialog'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDialog from '@nextcloud/vue/components/NcDialog'
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 NcTextArea from '@nextcloud/vue/components/NcTextArea'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import PlusIcon from '@icons/Plus.vue'
import PencilIcon from '@icons/Pencil.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import { isAdminRole, isModeratorRole, isDefaultRole, isGuestRole } from '@/constants'
import { useCategories } from '@/composables/useCategories'
import type { Category, CategoryPerm, CatHeader, Role, Team } from '@/types'
import PageHeader from '@/components/PageHeader'
type PermTarget = { id: string; label: string; type: 'role' | 'team' }
type ParentOption = { id: string; label: string; type: 'header' | 'category' }
export default defineComponent({
name: 'AdminCategoryEdit',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcDialog,
NcEmptyContent,
NcLoadingIcon,
NcSelect,
@@ -282,17 +262,21 @@ export default defineComponent({
FormSection,
CategoryCard,
ColorPickerPreset,
HeaderEditDialog,
ArrowLeftIcon,
PlusIcon,
PencilIcon,
PageHeader,
},
setup() {
const { categoryHeaders, fetchCategories, refresh: refreshCategories } = useCategories()
const {
categoryHeaders,
fetchCategories,
refresh: refreshCategories,
getAllCategoriesFlat,
} = useCategories()
return {
categoryHeaders,
fetchCategories,
refreshCategories,
getAllCategoriesFlat,
}
},
data() {
@@ -302,7 +286,7 @@ export default defineComponent({
error: null as string | null,
headers: [] as CatHeader[],
roles: [] as Role[],
selectedHeader: null as { id: number; label: string } | null,
selectedParent: null as ParentOption | null,
teams: [] as Team[],
selectedViewTargets: [] as PermTarget[],
selectedPostTargets: [] as PermTarget[],
@@ -310,21 +294,16 @@ export default defineComponent({
selectedModerateTargets: [] as PermTarget[],
formData: {
headerId: null as number | null,
parentId: null as number | null,
name: '',
slug: '',
description: '',
sortOrder: 0,
color: null as string | null,
textColor: 'dark' as 'light' | 'dark',
hideChildrenOnCard: false,
},
slugManuallyEdited: false,
headerDialog: {
show: false,
id: null as number | null,
name: '',
description: '',
sortOrder: 0,
},
strings: {
back: t('forum', 'Back'),
@@ -335,8 +314,8 @@ export default defineComponent({
errorTitle: t('forum', 'Error loading category'),
retry: t('forum', 'Retry'),
basicInfo: t('forum', 'Basic information'),
categoryHeader: t('forum', 'Category header'),
selectHeader: t('forum', '-- Select a header --'),
parent: t('forum', 'Parent'),
selectParent: t('forum', '-- Select a parent --'),
name: t('forum', 'Name'),
namePlaceholder: t('forum', 'Enter category name'),
slug: t('forum', 'Slug'),
@@ -353,8 +332,6 @@ export default defineComponent({
cancel: t('forum', 'Cancel'),
create: t('forum', 'Create'),
update: t('forum', 'Update'),
newHeader: t('forum', 'New'),
editHeader: t('forum', 'Edit'),
permissions: t('forum', 'Permissions'),
permissionsDescription: t(
'forum',
@@ -388,6 +365,11 @@ export default defineComponent({
darkText: t('forum', 'Dark text'),
lightText: t('forum', 'Light text'),
preview: t('forum', 'Preview'),
hideChildrenOnCard: t('forum', 'Hide subcategories on category card'),
hideChildrenOnCardHelp: t(
'forum',
"When enabled, child categories will not appear as links on this category's card on the home page",
),
},
}
},
@@ -400,16 +382,33 @@ export default defineComponent({
},
canSubmit(): boolean {
return (
this.selectedHeader !== null &&
this.selectedParent !== null &&
this.formData.name.trim().length > 0 &&
this.formData.slug.trim().length > 0
)
},
headerOptions(): Array<{ id: number; label: string }> {
return this.headers.map((header) => ({
id: header.id,
label: header.name,
}))
parentOptions(): ParentOption[] {
const options: ParentOption[] = []
// Get the set of descendant IDs to exclude (prevent circular refs)
const excludeIds = new Set<number>()
if (this.categoryId !== null) {
excludeIds.add(this.categoryId)
this.collectDescendantIds(this.categoryId, excludeIds)
}
for (const header of this.categoryHeaders) {
// Add header as a parent option
options.push({
id: `header:${header.id}`,
label: header.name,
type: 'header',
})
// Add categories nested under this header (with indentation)
if (header.categories) {
this.addCategoryOptions(header.categories, options, excludeIds, 1)
}
}
return options
},
teamOptions(): PermTarget[] {
return this.teams.map((team) => ({
@@ -479,12 +478,14 @@ export default defineComponent({
return {
id: 0,
headerId: 0,
parentId: null,
name: this.formData.name || this.strings.namePlaceholder,
description: this.formData.description || this.strings.descriptionPlaceholder,
slug: '',
sortOrder: 0,
color: this.formData.color,
textColor: this.formData.color ? this.formData.textColor : null,
hideChildrenOnCard: false,
threadCount: 0,
postCount: 0,
createdAt: 0,
@@ -493,14 +494,33 @@ export default defineComponent({
},
},
watch: {
selectedHeader(newVal: { id: number; label: string } | null) {
this.formData.headerId = newVal?.id || null
selectedParent(newVal: ParentOption | null) {
if (!newVal) {
this.formData.headerId = null
this.formData.parentId = null
return
}
if (newVal.type === 'header') {
this.formData.headerId = parseInt(newVal.id.split(':')[1])
this.formData.parentId = null
} else {
this.formData.parentId = parseInt(newVal.id.split(':')[1])
this.formData.headerId = null
}
// When creating a new category, auto-set sort order based on category count in the header
// When creating a new category, auto-set sort order based on sibling count
if (!this.isEditing && newVal) {
const header = this.categoryHeaders.find((h) => h.id === newVal.id)
const categoryCount = header?.categories?.length || 0
this.formData.sortOrder = categoryCount
if (newVal.type === 'header') {
const header = this.categoryHeaders.find(
(h) => h.id === parseInt(newVal.id.split(':')[1]),
)
this.formData.sortOrder = header?.categories?.length || 0
} else {
const allCats = this.getAllCategoriesFlat()
const parentId = parseInt(newVal.id.split(':')[1])
const siblings = allCats.filter((c) => c.parentId === parentId)
this.formData.sortOrder = siblings.length
}
}
},
'formData.name'(newVal: string) {
@@ -523,6 +543,38 @@ export default defineComponent({
this.refresh()
},
methods: {
/** Recursively add category options with indentation */
addCategoryOptions(
categories: Category[],
options: ParentOption[],
excludeIds: Set<number>,
depth: number,
): void {
for (const cat of categories) {
if (!excludeIds.has(cat.id)) {
const indent = '\u00A0\u00A0\u00A0\u00A0'.repeat(depth)
options.push({
id: `category:${cat.id}`,
label: `${indent}${cat.name}`,
type: 'category',
})
}
if (cat.children && cat.children.length > 0) {
this.addCategoryOptions(cat.children, options, excludeIds, depth + 1)
}
}
},
/** Collect all descendant IDs of a category */
collectDescendantIds(categoryId: number, result: Set<number>): void {
const allCats = this.getAllCategoriesFlat()
const children = allCats.filter((c) => c.parentId === categoryId)
for (const child of children) {
result.add(child.id)
this.collectDescendantIds(child.id, result)
}
},
toKebabCase(str: string): string {
return str
.trim()
@@ -602,22 +654,40 @@ export default defineComponent({
const category = categoryResponse.data
this.formData.headerId = category.headerId
this.formData.parentId = category.parentId
this.formData.name = category.name
this.formData.slug = category.slug
this.formData.description = category.description || ''
this.formData.sortOrder = category.sortOrder
this.formData.color = category.color || null
this.formData.textColor = category.textColor || 'dark'
this.formData.hideChildrenOnCard = category.hideChildrenOnCard || false
// When editing, don't track manual slug edits (slug is pre-populated from DB)
this.slugManuallyEdited = false
// Set selectedHeader based on headerId
const header = this.headers.find((h) => h.id === category.headerId)
if (header) {
this.selectedHeader = {
id: header.id,
label: header.name,
// Set selectedParent based on parentId or headerId
if (category.parentId !== null) {
// Find the parent category in the tree
const allCats = this.getAllCategoriesFlat()
const parentCat = allCats.find((c) => c.id === category.parentId)
if (parentCat) {
// Find depth for indentation
const option = this.parentOptions.find((o) => o.id === `category:${category.parentId}`)
this.selectedParent = option || {
id: `category:${parentCat.id}`,
label: parentCat.name,
type: 'category',
}
}
} else if (category.headerId !== null) {
const header = this.headers.find((h) => h.id === category.headerId)
if (header) {
this.selectedParent = {
id: `header:${header.id}`,
label: header.name,
type: 'header',
}
}
}
},
@@ -687,14 +757,23 @@ export default defineComponent({
try {
this.submitting = true
const categoryData = {
headerId: this.formData.headerId!,
const categoryData: Record<string, unknown> = {
name: this.formData.name.trim(),
slug: this.formData.slug.trim(),
description: this.formData.description.trim() || null,
sortOrder: this.formData.sortOrder,
color: this.formData.color || null,
textColor: this.formData.color ? this.formData.textColor : null,
hideChildrenOnCard: this.formData.hideChildrenOnCard,
}
// Set parent based on selection type
if (this.formData.parentId !== null) {
categoryData.parentId = this.formData.parentId
categoryData.headerId = null
} else {
categoryData.headerId = this.formData.headerId
categoryData.parentId = null
}
let categoryId: number
@@ -800,50 +879,6 @@ export default defineComponent({
goBack(): void {
this.$router.push('/admin/categories')
},
createNewHeader(): void {
this.headerDialog.show = true
this.headerDialog.id = null
this.headerDialog.name = ''
this.headerDialog.description = ''
// Set sort order to the count of headers (will be last)
this.headerDialog.sortOrder = this.categoryHeaders.length
},
editHeader(): void {
if (!this.selectedHeader) return
const header = this.categoryHeaders.find((h) => h.id === this.selectedHeader?.id)
if (!header) return
this.headerDialog.show = true
this.headerDialog.id = header.id
this.headerDialog.name = header.name
this.headerDialog.description = header.description || ''
this.headerDialog.sortOrder = header.sortOrder || 0
},
async handleHeaderSaved(savedHeader: CatHeader): Promise<void> {
// Update in local headers array
const index = this.headers.findIndex((h) => h.id === savedHeader.id)
if (index !== -1) {
this.headers[index] = savedHeader
} else {
// Add to local headers array if new
this.headers.push(savedHeader)
}
// Auto-select the new/updated header
this.selectedHeader = {
id: savedHeader.id,
label: savedHeader.name,
}
// Refresh sidebar categories
await this.refreshCategories()
this.headerDialog.show = false
},
},
})
</script>
@@ -928,6 +963,17 @@ export default defineComponent({
}
}
.hide-children-group {
display: flex;
flex-direction: column;
gap: 4px;
.help-text {
font-size: 0.85rem;
margin-top: 2px;
}
}
.design-preview {
display: flex;
flex-direction: column;

View File

@@ -98,63 +98,71 @@
</div>
<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"
<template
v-for="row in flattenCategoriesWithContext(header.categories, header.id)"
:key="row.category.id"
>
<div class="category-sort-buttons">
<NcButton
v-if="index > 0"
variant="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"
variant="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
class="category-row"
:class="{ 'subcategory-row': row.depth > 0 }"
:style="{ paddingLeft: `${16 + row.depth * 32}px` }"
>
<div class="category-sort-buttons">
<NcButton
v-if="row.index > 0"
variant="tertiary"
@click="reorderSiblings(row.siblings, row.index, -1)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="row.index < row.siblings.length - 1"
variant="tertiary"
@click="reorderSiblings(row.siblings, row.index, 1)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="category-meta muted">
<span>Slug: {{ category.slug }}</span>
<span></span>
<span>{{ strings.threadsCount(category.threadCount || 0) }}</span>
<span></span>
<span>{{ strings.postsCount(category.postCount || 0) }}</span>
<div class="category-info">
<div class="category-name" :class="{ 'subcategory-indicator': row.depth > 0 }">
<span v-if="row.depth > 0" class="subcategory-arrow"></span>
{{ row.category.name }}
</div>
<div v-if="row.category.description" class="category-desc muted">
{{ row.category.description }}
</div>
<div class="category-meta muted">
<span>Slug: {{ row.category.slug }}</span>
<span></span>
<span>{{ strings.threadsCount(row.category.threadCount || 0) }}</span>
<span></span>
<span>{{ strings.postsCount(row.category.postCount || 0) }}</span>
</div>
</div>
<div class="category-actions">
<NcButton @click="editCategory(row.category.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(row.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 variant="error" @click="confirmDelete(category)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</template>
</div>
<div v-else class="no-categories muted">
{{ strings.noCategories }}
@@ -462,16 +470,24 @@ export default defineComponent({
computed: {
targetCategoryOptions(): Array<{ id: number; label: string; disabled?: boolean }> {
const options: Array<{ id: number; label: string; disabled?: boolean }> = []
const deletingId = this.deleteDialog.category?.id
const addCats = (categories: Category[], prefix: string) => {
for (const cat of categories) {
options.push({
id: cat.id,
label: `${prefix} / ${cat.name}`,
disabled: cat.id === deletingId,
})
if (cat.children && cat.children.length > 0) {
addCats(cat.children, `${prefix} / ${cat.name}`)
}
}
}
this.categoryHeaders.forEach((header) => {
if (header.categories) {
header.categories.forEach((cat) => {
options.push({
id: cat.id,
label: `${header.name} / ${cat.name}`,
disabled: cat.id === this.deleteDialog.category?.id,
})
})
addCats(header.categories, header.name)
}
})
@@ -652,53 +668,64 @@ export default defineComponent({
}
},
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)
/**
* Flatten a category tree into rows with depth, index, and sibling info
* for rendering and reordering at any nesting level.
*/
flattenCategoriesWithContext(
categories: Category[],
_headerId: number,
depth = 0,
): Array<{
category: Category
depth: number
index: number
siblings: Category[]
}> {
const rows: Array<{
category: Category
depth: number
index: number
siblings: Category[]
}> = []
for (let i = 0; i < categories.length; i++) {
const cat = categories[i]!
rows.push({ category: cat, depth, index: i, siblings: categories })
if (cat.children && cat.children.length > 0) {
rows.push(...this.flattenCategoriesWithContext(cat.children, _headerId, depth + 1))
}
}
return rows
},
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]
const swapTarget = header.categories[index + amount]
/**
* Reorder siblings by swapping two adjacent items and persisting to backend.
*/
async reorderSiblings(siblings: Category[], index: number, amount: number): Promise<void> {
const temp = siblings[index]
const swapTarget = siblings[index + amount]
if (!temp || !swapTarget) return
header.categories[index] = swapTarget
header.categories[index + amount] = temp
// Swap locally
siblings[index] = swapTarget
siblings[index + amount] = temp
try {
// Build array of category IDs in their current order
const sortOrders = header.categories.map((category, idx) => ({
id: category.id,
const sortOrders = siblings.map((cat, idx) => ({
id: cat.id,
sortOrder: idx,
}))
await ocs.post('/categories/reorder', { categories: sortOrders })
// Refresh sidebar categories silently
await this.refreshCategories(true)
} catch (e) {
console.error('Failed to update category sort orders', e)
// Revert the swap on error
const revertTemp = header.categories[index + amount]
const revertCurrent = header.categories[index]
// Revert
const revertTemp = siblings[index + amount]
const revertCurrent = siblings[index]
if (revertTemp && revertCurrent) {
header.categories[index + amount] = revertCurrent
header.categories[index] = revertTemp
siblings[index + amount] = revertCurrent
siblings[index] = revertTemp
}
}
},
@@ -820,6 +847,21 @@ export default defineComponent({
gap: 8px;
}
}
.subcategory-row {
background: var(--color-background-dark);
}
.subcategory-indicator {
display: flex;
align-items: center;
gap: 4px;
}
.subcategory-arrow {
color: var(--color-text-maxcontrast);
font-size: 1.1rem;
}
}
.no-categories {

View File

@@ -1015,6 +1015,226 @@ class CategoryControllerTest extends TestCase {
$this->assertTrue($data['success']);
}
// ====== Subcategory Tests ======
public function testCreateCategoryWithParentId(): void {
$parentCategory = $this->createCategory(1, 1, 'Parent Category');
$createdChild = $this->createCategory(2, 1, 'Child Category', 1);
$this->categoryMapper->expects($this->once())
->method('find')
->with(1)
->willReturn($parentCategory);
$this->categoryMapper->expects($this->once())
->method('insert')
->willReturnCallback(function ($category) use ($createdChild) {
// Child categories should have null headerId and parentId set
$this->assertNull($category->getHeaderId());
$this->assertEquals(1, $category->getParentId());
return $createdChild;
});
$response = $this->controller->create(null, 'Child Category', 'child-category', null, 0, null, null, 1);
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
$data = $response->getData();
$this->assertEquals(1, $data['parentId']);
}
public function testCreateCategoryWithParentIdNotFound(): void {
$this->categoryMapper->expects($this->once())
->method('find')
->with(999)
->willThrowException(new DoesNotExistException('Not found'));
$response = $this->controller->create(null, 'Child', 'child', null, 0, null, null, 999);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
}
public function testCreateCategoryRequiresHeaderOrParent(): void {
$response = $this->controller->create(null, 'Orphan', 'orphan');
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
}
public function testUpdateCategorySetParentId(): void {
$category = $this->createCategory(1, 1, 'Category');
$parentCategory = $this->createCategory(2, 1, 'Parent');
$this->categoryMapper->method('find')
->willReturnMap([
[1, $category],
[2, $parentCategory],
]);
$this->categoryMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($cat) {
$this->assertEquals(2, $cat->getParentId());
$this->assertNull($cat->getHeaderId());
return $cat;
});
$response = $this->controller->update(1, null, null, null, null, null, '__unset__', '__unset__', '2');
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
public function testUpdateCategoryPreventCircularReferenceDirectChild(): void {
$parentCategory = $this->createCategory(1, 1, 'Parent');
$childCategory = $this->createCategory(2, 1, 'Child', 1);
$this->categoryMapper->method('find')
->willReturnMap([
[1, $parentCategory],
[2, $childCategory],
]);
// Try to set parent (1) as child of its own child (2)
$response = $this->controller->update(1, null, null, null, null, null, '__unset__', '__unset__', '2');
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
$data = $response->getData();
$this->assertStringContainsString('circular', $data['error']);
}
public function testUpdateCategoryPreventCircularReferenceDeeperDescendant(): void {
$grandparent = $this->createCategory(1, 1, 'Grandparent');
$parent = $this->createCategory(2, 1, 'Parent', 1);
$child = $this->createCategory(3, 1, 'Child', 2);
$this->categoryMapper->method('find')
->willReturnMap([
[1, $grandparent],
[2, $parent],
[3, $child],
]);
// Try to set grandparent (1) as child of its grandchild (3)
$response = $this->controller->update(1, null, null, null, null, null, '__unset__', '__unset__', '3');
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
$data = $response->getData();
$this->assertStringContainsString('circular', $data['error']);
}
public function testUpdateCategoryPreventSelfAsParent(): void {
$category = $this->createCategory(1, 1, 'Category');
$this->categoryMapper->method('find')
->willReturnMap([
[1, $category],
]);
// Try to set category as its own parent
$response = $this->controller->update(1, null, null, null, null, null, '__unset__', '__unset__', '1');
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
$data = $response->getData();
$this->assertStringContainsString('circular', $data['error']);
}
public function testUpdateCategorySetHideChildrenOnCard(): void {
$category = $this->createCategory(1, 1, 'Category');
$this->categoryMapper->expects($this->once())
->method('find')
->with(1)
->willReturn($category);
$this->categoryMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($cat) {
$this->assertTrue($cat->getHideChildrenOnCard());
return $cat;
});
$response = $this->controller->update(1, null, null, null, null, null, '__unset__', '__unset__', '__unset__', true);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
public function testDestroyReparentsChildren(): void {
$parent = $this->createCategory(1, 1, 'Parent');
$child1 = $this->createCategory(2, 1, 'Child 1', 1);
$child2 = $this->createCategory(3, 1, 'Child 2', 1);
$this->categoryMapper->expects($this->once())
->method('find')
->with(1)
->willReturn($parent);
$this->categoryMapper->expects($this->once())
->method('findByParentId')
->with(1)
->willReturn([$child1, $child2]);
// Children should be re-parented to parent's parent (null = top-level)
$updateCount = 0;
$this->categoryMapper->method('update')
->willReturnCallback(function ($cat) use (&$updateCount) {
$updateCount++;
$this->assertNull($cat->getParentId());
$this->assertEquals(1, $cat->getHeaderId());
return $cat;
});
$this->threadMapper->method('softDeleteByCategoryId')->willReturn(0);
$this->categoryMapper->expects($this->once())->method('delete')->with($parent);
$response = $this->controller->destroy(1);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$this->assertEquals(2, $updateCount);
}
public function testIndexGroupsChildCategoriesUnderParentHeader(): void {
$header = $this->createCatHeader(1, 'General');
$parent = $this->createCategory(1, 1, 'Parent');
$child = $this->createCategory(2, 1, 'Child', 1);
// Child has null headerId but should be grouped under header 1
$this->catHeaderMapper->method('findAll')->willReturn([$header]);
$this->categoryMapper->method('findAll')->willReturn([$parent, $child]);
$this->threadMapper->method('getLastActivityByCategories')->willReturn([]);
$response = $this->controller->index();
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertCount(1, $data); // One header
$this->assertCount(2, $data[0]['categories']); // Both parent and child under it
// Verify child has parentId set
$childData = array_values(array_filter($data[0]['categories'], fn ($c) => $c['id'] === 2));
$this->assertNotEmpty($childData);
$this->assertEquals(1, $childData[0]['parentId']);
}
public function testCategoryEntityJsonIncludesSubcategoryFields(): void {
$category = new Category();
$category->setId(1);
$category->setHeaderId(1);
$category->setParentId(5);
$category->setName('Test');
$category->setSlug('test');
$category->setSortOrder(0);
$category->setHideChildrenOnCard(true);
$category->setThreadCount(0);
$category->setPostCount(0);
$category->setCreatedAt(time());
$category->setUpdatedAt(time());
$json = $category->jsonSerialize();
$this->assertArrayHasKey('parentId', $json);
$this->assertEquals(5, $json['parentId']);
$this->assertArrayHasKey('hideChildrenOnCard', $json);
$this->assertTrue($json['hideChildrenOnCard']);
}
private function createCatHeader(int $id, string $name): CatHeader {
$header = new CatHeader();
$header->setId($id);
@@ -1024,14 +1244,16 @@ class CategoryControllerTest extends TestCase {
return $header;
}
private function createCategory(int $id, int $headerId, string $name): Category {
private function createCategory(int $id, int $headerId, string $name, ?int $parentId = null): Category {
$category = new Category();
$category->setId($id);
$category->setHeaderId($headerId);
$category->setHeaderId($parentId !== null ? null : $headerId);
$category->setParentId($parentId);
$category->setName($name);
$category->setSlug("category-$id");
$category->setDescription(null);
$category->setSortOrder(0);
$category->setHideChildrenOnCard(false);
$category->setThreadCount(0);
$category->setPostCount(0);
$category->setCreatedAt(time());