diff --git a/apps/files/src/actions/favoriteAction.spec.ts b/apps/files/src/actions/favoriteAction.spec.ts index 3c0d93bcb18..297e6fea7d9 100644 --- a/apps/files/src/actions/favoriteAction.spec.ts +++ b/apps/files/src/actions/favoriteAction.spec.ts @@ -217,7 +217,7 @@ describe('Favorite action execute tests', () => { // Check node change propagation expect(file.attributes.favorite).toBe(1) - expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalled() expect(eventBus.emit).toBeCalledWith('files:favorites:added', file) }) @@ -251,7 +251,7 @@ describe('Favorite action execute tests', () => { // Check node change propagation expect(file.attributes.favorite).toBe(0) - expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalled() expect(eventBus.emit).toBeCalledWith('files:favorites:removed', file) }) @@ -285,9 +285,9 @@ describe('Favorite action execute tests', () => { // Check node change propagation expect(file.attributes.favorite).toBe(0) - expect(eventBus.emit).toBeCalledTimes(2) - expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file) - expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:favorites:removed', file) + expect(eventBus.emit).toHaveBeenCalled() + expect(eventBus.emit).toHaveBeenCalledWith('files:node:deleted', file) + expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', file) }) test('Favorite does NOT triggers node removal if favorite view but NOT root dir', async () => { @@ -320,7 +320,7 @@ describe('Favorite action execute tests', () => { // Check node change propagation expect(file.attributes.favorite).toBe(0) - expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalled() expect(eventBus.emit).toBeCalledWith('files:favorites:removed', file) }) diff --git a/apps/files/src/actions/favoriteAction.ts b/apps/files/src/actions/favoriteAction.ts index 1d213a2081f..072118ad3ac 100644 --- a/apps/files/src/actions/favoriteAction.ts +++ b/apps/files/src/actions/favoriteAction.ts @@ -1,8 +1,9 @@ -/** +/* * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Node, View } from '@nextcloud/files' + +import type { INode, IView } from '@nextcloud/files' import StarOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw' import StarSvg from '@mdi/svg/svg/star.svg?raw' @@ -26,17 +27,18 @@ const queue = new PQueue({ concurrency: 5 }) * * @param nodes - The nodes to check */ -function shouldFavorite(nodes: Node[]): boolean { +function shouldFavorite(nodes: INode[]): boolean { return nodes.some((node) => node.attributes.favorite !== 1) } /** + * Favorite or unfavorite a node * - * @param node - * @param view - * @param willFavorite + * @param node - The node to favorite/unfavorite + * @param view - The current view + * @param willFavorite - Whether to favorite or unfavorite the node */ -export async function favoriteNode(node: Node, view: View, willFavorite: boolean): Promise { +export async function favoriteNode(node: INode, view: IView, willFavorite: boolean): Promise { try { // TODO: migrate to webdav tags plugin const url = generateUrl('/apps/files/api/v1/files') + encodePath(node.path) @@ -55,6 +57,7 @@ export async function favoriteNode(node: Node, view: View, willFavorite: boolean // Update the node webdav attribute Vue.set(node.attributes, 'favorite', willFavorite ? 1 : 0) + emit('files:node:updated', node) // Dispatch event to whoever is interested if (willFavorite) { diff --git a/apps/files/src/actions/sidebarFavoriteAction.ts b/apps/files/src/actions/sidebarFavoriteAction.ts new file mode 100644 index 00000000000..ba1c0d3b5f7 --- /dev/null +++ b/apps/files/src/actions/sidebarFavoriteAction.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import starOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw' +import starSvg from '@mdi/svg/svg/star.svg?raw' +import { registerSidebarAction } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { favoriteNode } from './favoriteAction.ts' + +/** + * Register the favorite/unfavorite action in the sidebar + */ +export function registerSidebarFavoriteAction() { + registerSidebarAction({ + id: 'files-favorite', + order: 0, + + enabled({ node }) { + return node.isDavResource && node.root.startsWith('/files/') + }, + + displayName({ node }) { + if (node.attributes.favorite) { + return t('files', 'Unfavorite') + } + return t('files', 'Favorite') + }, + + iconSvgInline({ node }) { + if (node.attributes.favorite) { + return starSvg + } + return starOutlineSvg + }, + + onClick({ node, view }) { + favoriteNode(node, view, !node.attributes.favorite) + }, + }) +} diff --git a/apps/files/src/composables/useHotKeys.spec.ts b/apps/files/src/composables/useHotKeys.spec.ts index 05545561c0b..626f0b7182a 100644 --- a/apps/files/src/composables/useHotKeys.spec.ts +++ b/apps/files/src/composables/useHotKeys.spec.ts @@ -4,6 +4,7 @@ */ import type { View } from '@nextcloud/files' +import type { Mock } from 'vitest' import type { Location } from 'vue-router' import axios from '@nextcloud/axios' @@ -143,6 +144,9 @@ describe('HotKeysService testing', () => { }) it('Pressing s should toggle favorite', () => { + (favoriteAction.enabled as Mock).mockReturnValue(true); + (favoriteAction.exec as Mock).mockImplementationOnce(() => Promise.resolve(null)) + vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve()) dispatchEvent({ key: 's', code: 'KeyS' }) @@ -152,7 +156,6 @@ describe('HotKeysService testing', () => { dispatchEvent({ key: 's', code: 'KeyS', shiftKey: true }) dispatchEvent({ key: 's', code: 'KeyS', metaKey: true }) - expect(favoriteAction.enabled).toHaveReturnedWith(true) expect(favoriteAction.exec).toHaveBeenCalledOnce() }) diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index 563d06b8ab1..42876b5c6b2 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -16,6 +16,7 @@ import { action as openInFilesAction } from './actions/openInFilesAction.ts' import { action as editLocallyAction } from './actions/openLocallyAction.ts' import { action as renameAction } from './actions/renameAction.ts' import { action as sidebarAction } from './actions/sidebarAction.ts' +import { registerSidebarFavoriteAction } from './actions/sidebarFavoriteAction.ts' import { action as viewInFolderAction } from './actions/viewInFolderAction.ts' import { registerFilenameFilter } from './filters/FilenameFilter.ts' import { registerHiddenFilesFilter } from './filters/HiddenFilesFilter.ts' @@ -69,6 +70,9 @@ registerModifiedFilter() registerFilenameFilter() registerFilterToSearchToggle() +// Register sidebar action +registerSidebarFavoriteAction() + // Register preview service worker registerPreviewServiceWorker() diff --git a/apps/files/src/services/WebdavClient.ts b/apps/files/src/services/WebdavClient.ts index 164974ddb07..2985fd60131 100644 --- a/apps/files/src/services/WebdavClient.ts +++ b/apps/files/src/services/WebdavClient.ts @@ -10,8 +10,9 @@ import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextc export const client = getClient() /** + * Fetches a node from the given path * - * @param path + * @param path - The path to fetch the node from */ export async function fetchNode(path: string): Promise { const propfindPayload = getDefaultPropfind() diff --git a/apps/files/src/views/favorites.spec.ts b/apps/files/src/views/favorites.spec.ts index 39a25b4977c..a31f291a934 100644 --- a/apps/files/src/views/favorites.spec.ts +++ b/apps/files/src/views/favorites.spec.ts @@ -130,9 +130,9 @@ describe('Favorites view definition', () => { describe('Dynamic update of favorite folders', () => { let Navigation + beforeEach(() => { vi.restoreAllMocks() - delete window._nc_navigation Navigation = getNavigation() }) @@ -167,8 +167,9 @@ describe('Dynamic update of favorite folders', () => { contents: [], }) - expect(eventBus.emit).toHaveBeenCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalledTimes(2) expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder) + expect(eventBus.emit).toHaveBeenCalledWith('files:node:updated', folder) }) test('Remove a favorite folder remove the entry from the navigation column', async () => { @@ -213,8 +214,9 @@ describe('Dynamic update of favorite folders', () => { contents: [], }) - expect(eventBus.emit).toHaveBeenCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalledTimes(2) expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', folder) + expect(eventBus.emit).toHaveBeenCalledWith('files:node:updated', folder) expect(fo).toHaveBeenCalled() favoritesView = Navigation.views.find((view) => view.id === 'favorites') @@ -257,7 +259,8 @@ describe('Dynamic update of favorite folders', () => { folder: {} as NcFolder, contents: [], }) - expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:favorites:added', folder) + expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder) + expect(eventBus.emit).toHaveBeenCalledWith('files:node:updated', folder) // Create a folder with the same id but renamed const renamedFolder = new Folder({ @@ -269,6 +272,6 @@ describe('Dynamic update of favorite folders', () => { // Exec the rename action eventBus.emit('files:node:renamed', renamedFolder) - expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:renamed', renamedFolder) + expect(eventBus.emit).toHaveBeenCalledWith('files:node:renamed', renamedFolder) }) }) diff --git a/apps/files_sharing/src/views/FilesSidebarTab.vue b/apps/files_sharing/src/views/FilesSidebarTab.vue index e46e78722c0..b4ef10ec37e 100644 --- a/apps/files_sharing/src/views/FilesSidebarTab.vue +++ b/apps/files_sharing/src/views/FilesSidebarTab.vue @@ -1,3 +1,8 @@ + +