feat: allow updating thread title (author/admin/moderator)

This commit is contained in:
2025-11-25 10:52:34 +02:00
parent 33e6055d47
commit a85dbaed91
3 changed files with 342 additions and 17 deletions

View File

@@ -15,6 +15,7 @@ use OCA\Forum\Db\Thread;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\PermissionService;
use OCA\Forum\Service\ThreadEnrichmentService;
use OCA\Forum\Service\UserPreferencesService;
use OCA\Forum\Service\UserService;
@@ -41,6 +42,7 @@ class ThreadController extends OCSController {
private ThreadEnrichmentService $threadEnrichmentService,
private UserPreferencesService $userPreferencesService,
private UserService $userService,
private PermissionService $permissionService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -318,15 +320,45 @@ class ThreadController extends OCSController {
* 200: Thread updated
*/
#[NoAdminRequired]
#[RequirePermission('canModerate', resourceType: 'category', resourceIdFromThreadId: 'id')]
#[RequirePermission('canView', resourceType: 'category', resourceIdFromThreadId: 'id')]
#[ApiRoute(verb: 'PUT', url: '/api/threads/{id}')]
public function update(int $id, ?string $title = null, ?bool $isLocked = null, ?bool $isPinned = null, ?bool $isHidden = null): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$thread = $this->threadMapper->find($id);
// Check if user is the author or has moderation permission
$isAuthor = $thread->getAuthorId() === $user->getUID();
$canModerate = $this->permissionService->hasCategoryPermission(
$user->getUID(),
$thread->getCategoryId(),
'canModerate'
);
// Title can be updated by author or moderator
if ($title !== null) {
if (!$isAuthor && !$canModerate) {
return new DataResponse(
['error' => 'You do not have permission to edit this thread title'],
Http::STATUS_FORBIDDEN
);
}
$thread->setTitle($title);
}
// Lock, pin, and hidden status can only be updated by moderators
if (($isLocked !== null || $isPinned !== null || $isHidden !== null) && !$canModerate) {
return new DataResponse(
['error' => 'You do not have permission to modify thread status'],
Http::STATUS_FORBIDDEN
);
}
if ($isLocked !== null) {
$thread->setIsLocked($isLocked);
}
@@ -340,7 +372,7 @@ class ThreadController extends OCSController {
/** @var \OCA\Forum\Db\Thread */
$updatedThread = $this->threadMapper->update($thread);
return new DataResponse($updatedThread->jsonSerialize());
return new DataResponse($this->threadEnrichmentService->enrichThread($updatedThread));
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {

View File

@@ -105,15 +105,51 @@
<!-- Thread Header -->
<div v-else-if="thread" class="thread-header mt-16">
<div class="thread-title-section">
<h2 class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
<PinIcon :size="20" />
</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
<LockIcon :size="20" />
</span>
{{ thread.title }}
</h2>
<div class="thread-title-row">
<h2 v-if="!isEditingTitle" class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
<PinIcon :size="20" />
</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
<LockIcon :size="20" />
</span>
{{ thread.title }}
</h2>
<NcTextField
v-else
v-model="editedTitle"
class="thread-title-input"
@keydown.enter="handleSaveTitle"
@keydown.esc="handleCancelEditTitle"
ref="titleInput"
:disabled="isSavingTitle"
/>
<NcButton
v-if="!isEditingTitle && canEditTitle"
@click="handleStartEditTitle"
type="tertiary"
:aria-label="strings.editTitle"
:title="strings.editTitle"
class="edit-title-button"
>
<template #icon>
<PencilIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="isEditingTitle"
@click="handleSaveTitle"
:disabled="isSavingTitle || !editedTitle.trim()"
type="primary"
:aria-label="strings.saveTitle"
:title="strings.saveTitle"
class="save-title-button"
>
<template #icon>
<CheckIcon :size="20" />
</template>
</NcButton>
</div>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
@@ -216,6 +252,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PostCard from '@/components/PostCard.vue'
@@ -230,6 +267,8 @@ import BellIcon from '@icons/Bell.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
import ReplyIcon from '@icons/Reply.vue'
import PencilIcon from '@icons/Pencil.vue'
import CheckIcon from '@icons/Check.vue'
import type { Post } from '@/types'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
@@ -248,6 +287,7 @@ export default defineComponent({
NcLoadingIcon,
NcDateTime,
NcNoteCard,
NcTextField,
AppToolbar,
PageWrapper,
PostCard,
@@ -262,6 +302,8 @@ export default defineComponent({
ArrowLeftIcon,
RefreshIcon,
ReplyIcon,
PencilIcon,
CheckIcon,
},
setup() {
const { currentThread: thread, fetchThread } = useCurrentThread()
@@ -285,6 +327,9 @@ export default defineComponent({
offset: 0,
postCardRefs: new Map<number, any>(),
canModerate: false,
isEditingTitle: false,
editedTitle: '',
isSavingTitle: false,
strings: {
back: t('forum', 'Back'),
@@ -317,6 +362,9 @@ export default defineComponent({
subscribed: t('forum', 'Subscribed'),
threadSubscribed: t('forum', 'Subscribed to thread'),
threadUnsubscribed: t('forum', 'Unsubscribed from thread'),
editTitle: t('forum', 'Edit title'),
saveTitle: t('forum', 'Save title'),
titleUpdated: t('forum', 'Thread title updated'),
},
}
},
@@ -327,6 +375,10 @@ export default defineComponent({
threadSlug(): string | null {
return (this.$route.params.slug as string) || null
},
canEditTitle(): boolean {
// Allow if user is the author, or has moderation permissions
return this.thread?.authorId === this.userId || this.canModerate
},
},
watch: {
'$route.hash'(newHash: string) {
@@ -775,6 +827,61 @@ export default defineComponent({
this.$router.push('/')
}
},
handleStartEditTitle(): void {
if (!this.thread) return
this.editedTitle = this.thread.title
this.isEditingTitle = true
// Focus the input after it's rendered
this.$nextTick(() => {
const textFieldComponent = this.$refs.titleInput as any
if (textFieldComponent && textFieldComponent.$el) {
const input = textFieldComponent.$el.querySelector('input')
if (input) {
input.focus()
input.select()
}
}
})
},
handleCancelEditTitle(): void {
this.isEditingTitle = false
this.editedTitle = ''
},
async handleSaveTitle(): Promise<void> {
if (!this.thread || !this.editedTitle.trim() || this.isSavingTitle) return
// Don't save if title hasn't changed
if (this.editedTitle.trim() === this.thread.title) {
this.handleCancelEditTitle()
return
}
this.isSavingTitle = true
try {
const response = await ocs.put(`/threads/${this.thread.id}/title`, {
title: this.editedTitle.trim(),
})
if (response.data) {
// Update local thread state
this.thread.title = this.editedTitle.trim()
showSuccess(this.strings.titleUpdated)
this.isEditingTitle = false
this.editedTitle = ''
}
} catch (e) {
console.error('Failed to update thread title', e)
showError(t('forum', 'Failed to update thread title'))
} finally {
this.isSavingTitle = false
}
},
},
})
</script>
@@ -835,6 +942,12 @@ export default defineComponent({
gap: 8px;
}
.thread-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.thread-title {
margin: 0;
font-size: 1.75rem;
@@ -846,6 +959,22 @@ export default defineComponent({
flex-wrap: wrap;
}
.thread-title-input {
flex: 1;
:deep(input) {
font-size: 1.75rem;
font-weight: 600;
color: var(--color-main-text);
font-family: inherit;
}
}
.edit-title-button,
.save-title-button {
flex-shrink: 0;
}
.badge {
font-size: 1.2rem;
display: inline-flex;

View File

@@ -15,6 +15,7 @@ use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCA\Forum\Db\UserStats;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\PermissionService;
use OCA\Forum\Service\ThreadEnrichmentService;
use OCA\Forum\Service\UserPreferencesService;
use OCA\Forum\Service\UserService;
@@ -36,6 +37,7 @@ class ThreadControllerTest extends TestCase {
private ThreadEnrichmentService $threadEnrichmentService;
private UserPreferencesService $userPreferencesService;
private UserService $userService;
private PermissionService $permissionService;
private IUserSession $userSession;
private LoggerInterface $logger;
private IRequest $request;
@@ -50,6 +52,7 @@ class ThreadControllerTest extends TestCase {
$this->threadEnrichmentService = $this->createMock(ThreadEnrichmentService::class);
$this->userPreferencesService = $this->createMock(UserPreferencesService::class);
$this->userService = $this->createMock(UserService::class);
$this->permissionService = $this->createMock(PermissionService::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->logger = $this->createMock(LoggerInterface::class);
@@ -75,6 +78,7 @@ class ThreadControllerTest extends TestCase {
$this->threadEnrichmentService,
$this->userPreferencesService,
$this->userService,
$this->permissionService,
$this->userSession,
$this->logger
);
@@ -348,16 +352,27 @@ class ThreadControllerTest extends TestCase {
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
}
public function testUpdateThreadSuccessfully(): void {
public function testUpdateThreadTitleSuccessfullyAsAuthor(): void {
$threadId = 1;
$categoryId = 1;
$authorId = 'user1';
$newTitle = 'Updated Title';
$thread = $this->createMockThread($threadId, 1, 'user1', 'Original Title');
$thread = $this->createMockThread($threadId, $categoryId, $authorId, 'Original Title');
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($authorId);
$this->userSession->method('getUser')->willReturn($user);
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willReturn($thread);
$this->permissionService->expects($this->once())
->method('hasCategoryPermission')
->with($authorId, $categoryId, 'canModerate')
->willReturn(false);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) use ($newTitle) {
@@ -372,15 +387,93 @@ class ThreadControllerTest extends TestCase {
$this->assertEquals($threadId, $data['id']);
}
public function testUpdateThreadLockedStatus(): void {
public function testUpdateThreadTitleSuccessfullyAsModerator(): void {
$threadId = 1;
$thread = $this->createMockThread($threadId, 1, 'user1', 'Test Thread');
$categoryId = 1;
$authorId = 'user1';
$moderatorId = 'moderator1';
$newTitle = 'Updated Title';
$thread = $this->createMockThread($threadId, $categoryId, $authorId, 'Original Title');
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($moderatorId);
$this->userSession->method('getUser')->willReturn($user);
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willReturn($thread);
$this->permissionService->expects($this->once())
->method('hasCategoryPermission')
->with($moderatorId, $categoryId, 'canModerate')
->willReturn(true);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) use ($newTitle) {
$this->assertEquals($newTitle, $updatedThread->getTitle());
return $updatedThread;
});
$response = $this->controller->update($threadId, $newTitle);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertEquals($threadId, $data['id']);
}
public function testUpdateThreadTitleReturnsForbiddenWhenUserIsNeitherAuthorNorModerator(): void {
$threadId = 1;
$categoryId = 1;
$authorId = 'user1';
$otherUserId = 'user2';
$newTitle = 'Updated Title';
$thread = $this->createMockThread($threadId, $categoryId, $authorId, 'Original Title');
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($otherUserId);
$this->userSession->method('getUser')->willReturn($user);
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willReturn($thread);
$this->permissionService->expects($this->once())
->method('hasCategoryPermission')
->with($otherUserId, $categoryId, 'canModerate')
->willReturn(false);
$this->threadMapper->expects($this->never())
->method('update');
$response = $this->controller->update($threadId, $newTitle);
$this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
$this->assertEquals(['error' => 'You do not have permission to edit this thread title'], $response->getData());
}
public function testUpdateThreadLockedStatus(): void {
$threadId = 1;
$categoryId = 1;
$moderatorId = 'moderator1';
$thread = $this->createMockThread($threadId, $categoryId, 'user1', 'Test Thread');
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($moderatorId);
$this->userSession->method('getUser')->willReturn($user);
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willReturn($thread);
$this->permissionService->expects($this->once())
->method('hasCategoryPermission')
->with($moderatorId, $categoryId, 'canModerate')
->willReturn(true);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
@@ -395,13 +488,24 @@ class ThreadControllerTest extends TestCase {
public function testUpdateThreadPinnedStatus(): void {
$threadId = 1;
$thread = $this->createMockThread($threadId, 1, 'user1', 'Test Thread');
$categoryId = 1;
$moderatorId = 'moderator1';
$thread = $this->createMockThread($threadId, $categoryId, 'user1', 'Test Thread');
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($moderatorId);
$this->userSession->method('getUser')->willReturn($user);
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willReturn($thread);
$this->permissionService->expects($this->once())
->method('hasCategoryPermission')
->with($moderatorId, $categoryId, 'canModerate')
->willReturn(true);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
@@ -416,13 +520,24 @@ class ThreadControllerTest extends TestCase {
public function testUpdateThreadHiddenStatus(): void {
$threadId = 1;
$thread = $this->createMockThread($threadId, 1, 'user1', 'Test Thread');
$categoryId = 1;
$moderatorId = 'moderator1';
$thread = $this->createMockThread($threadId, $categoryId, 'user1', 'Test Thread');
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($moderatorId);
$this->userSession->method('getUser')->willReturn($user);
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willReturn($thread);
$this->permissionService->expects($this->once())
->method('hasCategoryPermission')
->with($moderatorId, $categoryId, 'canModerate')
->willReturn(true);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
@@ -435,8 +550,28 @@ class ThreadControllerTest extends TestCase {
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
public function testUpdateThreadReturnsUnauthorizedWhenUserNotAuthenticated(): void {
$threadId = 1;
$newTitle = 'New Title';
$this->userSession->method('getUser')->willReturn(null);
$this->threadMapper->expects($this->never())
->method('find');
$response = $this->controller->update($threadId, $newTitle);
$this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus());
$this->assertEquals(['error' => 'User not authenticated'], $response->getData());
}
public function testUpdateThreadReturnsNotFoundWhenThreadDoesNotExist(): void {
$threadId = 999;
$userId = 'user1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$this->threadMapper->expects($this->once())
->method('find')
@@ -449,6 +584,35 @@ class ThreadControllerTest extends TestCase {
$this->assertEquals(['error' => 'Thread not found'], $response->getData());
}
public function testUpdateThreadStatusReturnsForbiddenWhenUserLacksModeratePermission(): void {
$threadId = 1;
$categoryId = 1;
$userId = 'user1';
$thread = $this->createMockThread($threadId, $categoryId, $userId, 'Test Thread');
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willReturn($thread);
$this->permissionService->expects($this->once())
->method('hasCategoryPermission')
->with($userId, $categoryId, 'canModerate')
->willReturn(false);
$this->threadMapper->expects($this->never())
->method('update');
$response = $this->controller->update($threadId, null, true);
$this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
$this->assertEquals(['error' => 'You do not have permission to modify thread status'], $response->getData());
}
public function testDestroyThreadSuccessfully(): void {
$threadId = 1;
$categoryId = 1;