Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/files/src/components/FilesListHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</template>

<script lang="ts">
import type { Folder, Header, View } from '@nextcloud/files'
import type { Folder, IFileListHeader, View } from '@nextcloud/files'
import type { PropType } from 'vue'

import PQueue from 'p-queue'
Expand All @@ -25,7 +25,7 @@ export default {
name: 'FilesListHeader',
props: {
header: {
type: Object as PropType<Header>,
type: Object as PropType<IFileListHeader>,
required: true,
},

Expand Down
60 changes: 40 additions & 20 deletions apps/files/src/composables/useFileListHeaders.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,59 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { Header } from '@nextcloud/files'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFileListHeaders } from './useFileListHeaders.ts'
import type { IFileListHeader } from '@nextcloud/files'
import type { registerFileListHeader } from '@nextcloud/files'
import type { ComputedRef } from 'vue'

const getFileListHeaders = vi.hoisted(() => vi.fn())
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'

vi.mock('@nextcloud/files', async (originalModule) => {
return {
...(await originalModule()),
getFileListHeaders,
}
})
interface Context {
useFileListHeaders: () => ComputedRef<IFileListHeader[]>
registerFileListHeader: typeof registerFileListHeader
}

describe('useFileListHeaders', () => {
beforeEach(() => vi.resetAllMocks())
beforeEach(async (context: Context) => {
delete globalThis._nc_files_scope
// reset modules to reset internal variables (the headers ref) of the composable and the library (the scoped globals)
vi.resetModules()
context.useFileListHeaders = (await import('./useFileListHeaders.ts')).useFileListHeaders
context.registerFileListHeader = (await import('@nextcloud/files')).registerFileListHeader
})

it('gets the headers', () => {
const header = new Header({ id: '1', order: 5, render: vi.fn(), updated: vi.fn() })
getFileListHeaders.mockImplementationOnce(() => [header])
it<Context>('gets the headers', ({ useFileListHeaders, registerFileListHeader }) => {
const header: IFileListHeader = { id: '1', order: 5, render: vi.fn(), updated: vi.fn() }
registerFileListHeader(header)

const headers = useFileListHeaders()
expect(headers.value).toEqual([header])
expect(getFileListHeaders).toHaveBeenCalledOnce()
})

it('headers are sorted', () => {
const header = new Header({ id: '1', order: 10, render: vi.fn(), updated: vi.fn() })
const header2 = new Header({ id: '2', order: 5, render: vi.fn(), updated: vi.fn() })
getFileListHeaders.mockImplementationOnce(() => [header, header2])
it<Context>('headers are sorted', ({ useFileListHeaders, registerFileListHeader }) => {
const header: IFileListHeader = { id: '1', order: 10, render: vi.fn(), updated: vi.fn() }
const header2: IFileListHeader = { id: '2', order: 5, render: vi.fn(), updated: vi.fn() }
registerFileListHeader(header)
registerFileListHeader(header2)

const headers = useFileListHeaders()
// lower order first
expect(headers.value.map(({ id }) => id)).toStrictEqual(['2', '1'])
expect(getFileListHeaders).toHaveBeenCalledOnce()
})

it<Context>('composable is reactive', async ({ useFileListHeaders, registerFileListHeader }) => {
const header: IFileListHeader = { id: 'a', order: 10, render: vi.fn(), updated: vi.fn() }
registerFileListHeader(header)
await nextTick()

const headers = useFileListHeaders()
expect(headers.value.map(({ id }) => id)).toStrictEqual(['a'])
// now add a new header
const header2: IFileListHeader = { id: 'b', order: 5, render: vi.fn(), updated: vi.fn() }
registerFileListHeader(header2)

// reactive update, lower order first
await nextTick()
expect(headers.value.map(({ id }) => id)).toStrictEqual(['b', 'a'])
})
})
19 changes: 14 additions & 5 deletions apps/files/src/composables/useFileListHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,27 @@
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Header } from '@nextcloud/files'

import type { IFileListHeader } from '@nextcloud/files'
import type { ComputedRef } from 'vue'

import { getFileListHeaders } from '@nextcloud/files'
import { getFileListHeaders, getFilesRegistry } from '@nextcloud/files'
import { computed, ref } from 'vue'

const headers = ref<IFileListHeader[]>()
const sorted = computed(() => [...(headers.value ?? [])].sort((a, b) => a.order - b.order) as IFileListHeader[])

/**
* Get the registered and sorted file list headers.
*/
export function useFileListHeaders(): ComputedRef<Header[]> {
const headers = ref(getFileListHeaders())
const sorted = computed(() => [...headers.value].sort((a, b) => a.order - b.order) as Header[])
export function useFileListHeaders(): ComputedRef<IFileListHeader[]> {
if (!headers.value) {
// if not initialized by other component yet, initialize and subscribe to registry changes
headers.value = getFileListHeaders()
getFilesRegistry().addEventListener('register:listHeader', () => {
headers.value = getFileListHeaders()
})
}

return sorted
}
13 changes: 12 additions & 1 deletion apps/files/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { addNewFileMenuEntry, registerFileAction } from '@nextcloud/files'
import { addNewFileMenuEntry, getNewFileMenu, registerFileAction } from '@nextcloud/files'
import { registerDavProperty } from '@nextcloud/files/dav'
import { isPublicShare } from '@nextcloud/sharing/public'
import { registerConvertActions } from './actions/convertAction.ts'
Expand Down Expand Up @@ -79,3 +79,14 @@ registerDavProperty('nc:is-mount-root', { nc: 'http://nextcloud.org/ns' })
registerDavProperty('nc:metadata-blurhash', { nc: 'http://nextcloud.org/ns' })

initLivePhotos()

// TODO: REMOVE THIS ONCE THE UPLOAD LIBRARY IS MIGRATED TO THE NEW FILES LIBRARY
window._nc_newfilemenu = new Proxy(getNewFileMenu(), {
get(target, prop) {
return target[prop as keyof typeof target]
},
set(target, prop, value) {
target[prop as keyof typeof target] = value
return true
},
})
73 changes: 39 additions & 34 deletions apps/files/src/newMenu/newFolder.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,34 @@
/**
/*!
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { NewMenuEntry, Node } from '@nextcloud/files'

import type { IFolder, INode, NewMenuEntry } from '@nextcloud/files'

import FolderPlusSvg from '@mdi/svg/svg/folder-plus-outline.svg?raw'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { Folder, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { t } from '@nextcloud/l10n'
import { basename } from 'path'
import logger from '../logger.ts'
import { newNodeName } from '../utils/newNodeDialog.ts'

type createFolderResponse = {
fileid: number
source: string
}

/**
*
* @param root
* @param name
*/
async function createNewFolder(root: Folder, name: string): Promise<createFolderResponse> {
const source = root.source + '/' + name
const encodedSource = root.encodedSource + '/' + encodeURIComponent(name)

const response = await axios({
method: 'MKCOL',
url: encodedSource,
headers: {
Overwrite: 'F',
},
})
return {
fileid: parseInt(response.headers['oc-fileid']),
source,
}
}

export const entry: NewMenuEntry = {
id: 'newFolder',
order: 0,
displayName: t('files', 'New folder'),
enabled: (context: Folder) => Boolean(context.permissions & Permission.CREATE) && Boolean(context.permissions & Permission.READ),

// Make the svg icon color match the primary element color
iconSvgInline: FolderPlusSvg.replace(/viewBox/gi, 'style="color: var(--color-primary-element)" viewBox'),
order: 0,

async handler(context: Folder, content: Node[]) {
enabled(context) {
return Boolean(context.permissions & Permission.CREATE)
&& Boolean(context.permissions & Permission.READ)
},

async handler(context: IFolder, content: INode[]) {
const name = await newNodeName(t('files', 'New folder'), content)
if (name === null) {
return
Expand Down Expand Up @@ -92,3 +69,31 @@ export const entry: NewMenuEntry = {
}
},
}

type createFolderResponse = {
fileid: number
source: string
}

/**
* Create a new folder in the given root with the given name
*
* @param root - The folder in which the new folder should be created
* @param name - The name of the new folder
*/
async function createNewFolder(root: IFolder, name: string): Promise<createFolderResponse> {
const source = root.source + '/' + name
const encodedSource = root.encodedSource + '/' + encodeURIComponent(name)

const response = await axios({
method: 'MKCOL',
url: encodedSource,
headers: {
Overwrite: 'F',
},
})
return {
fileid: parseInt(response.headers['oc-fileid']),
source,
}
}
67 changes: 41 additions & 26 deletions apps/files/src/store/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip, IFileListFilterWithUi } from '@nextcloud/files'

import { emit, subscribe } from '@nextcloud/event-bus'
import { getFileListFilters } from '@nextcloud/files'
import { getFileListFilters, getFilesRegistry } from '@nextcloud/files'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import logger from '../logger.ts'
Expand Down Expand Up @@ -63,8 +63,8 @@ export const useFiltersStore = defineStore('filters', () => {
const index = filters.value.findIndex(({ id }) => id === filterId)
if (index > -1) {
const [filter] = filters.value.splice(index, 1)
filter.removeEventListener('update:chips', onFilterUpdateChips)
filter.removeEventListener('update:filter', onFilterUpdate)
filter!.removeEventListener('update:chips', onFilterUpdateChips)
filter!.removeEventListener('update:filter', onFilterUpdate)
logger.debug('Files list filter unregistered', { id: filterId })
}
}
Expand Down Expand Up @@ -92,9 +92,47 @@ export const useFiltersStore = defineStore('filters', () => {
logger.debug('File list filter chips updated', { filter: id, chips: event.detail })
}

initialize()

return {
// state
chips,
filters,
filtersWithUI,

// getters / computed
activeChips,
sortedFilters,
}

/**
* Initialize the store by registering event listeners and loading initial filters.
*
* @internal
*/
function initialize() {
const registry = getFilesRegistry()
const initialFilters = getFileListFilters()
// handle adding and removing filters after initialization
registry.addEventListener('register:listFilter', (event) => {
addFilter(event.detail)
})
registry.addEventListener('unregister:listFilter', (event) => {
removeFilter(event.detail)
})
// register the initial filters
for (const filter of initialFilters) {
addFilter(filter)
}

// subscribe to file list view changes to reset the filters
subscribe('files:navigation:changed', onViewChanged)
}

/**
* Event handler that resets all filters if the file list view was changed.
*
* @internal
*/
function onViewChanged() {
logger.debug('Reset all file list filters - view changed')
Expand All @@ -105,27 +143,4 @@ export const useFiltersStore = defineStore('filters', () => {
}
}
}

// Initialize the store
subscribe('files:navigation:changed', onViewChanged)
subscribe('files:filter:added', addFilter)
subscribe('files:filter:removed', removeFilter)
for (const filter of getFileListFilters()) {
addFilter(filter)
}

return {
// state
chips,
filters,
filtersWithUI,

// getters / computed
activeChips,
sortedFilters,

// actions / methods
addFilter,
removeFilter,
}
})
6 changes: 3 additions & 3 deletions apps/files/src/utils/newNodeDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { Node } from '@nextcloud/files'
import type { INode } from '@nextcloud/files'

import { spawnDialog } from '@nextcloud/vue/functions/dialog'
import NewNodeDialog from '../components/NewNodeDialog.vue'
Expand All @@ -27,8 +27,8 @@ interface ILabels {
* @param labels Labels to set on the dialog
* @return string if successful otherwise null if aborted
*/
export function newNodeName(defaultName: string, folderContent: Node[], labels: ILabels = {}) {
const contentNames = folderContent.map((node: Node) => node.basename)
export function newNodeName(defaultName: string, folderContent: INode[], labels: ILabels = {}) {
const contentNames = folderContent.map((node: INode) => node.basename)

return new Promise<string | null>((resolve) => {
spawnDialog(NewNodeDialog, {
Expand Down
Loading
Loading