mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
feat: allow category nesting
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
55
lib/Migration/Version29Date20260402000000.php
Normal file
55
lib/Migration/Version29Date20260402000000.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
openapi.json
37
openapi.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
86
src/components/AppNavigation/NavCategoryItem.test.ts
Normal file
86
src/components/AppNavigation/NavCategoryItem.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
47
src/components/AppNavigation/NavCategoryItem.vue
Normal file
47
src/components/AppNavigation/NavCategoryItem.vue
Normal 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>
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
332
src/composables/__tests__/useCategories.test.ts
Normal file
332
src/composables/__tests__/useCategories.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
391
src/views/__tests__/AdminCategoryEdit.test.ts
Normal file
391
src/views/__tests__/AdminCategoryEdit.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
332
src/views/__tests__/AdminCategoryList.test.ts
Normal file
332
src/views/__tests__/AdminCategoryList.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user