Skip to content
Open
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
117 changes: 117 additions & 0 deletions apps/systemtags/src/components/FileListFilterTags.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div>
<NcTextField
v-if="availableTags.length > 5"
v-model="searchQuery"
type="search"
:label="t('systemtags', 'Search tags')" />
<NcButton
v-for="tag of shownTags"
:key="tag.id"
alignment="start"
:pressed="isSelected(tag)"
variant="tertiary"
wide
@update:pressed="toggleTag(tag, $event)">
<template #icon>
<NcIconSvgWrapper :path="mdiTagOutline" />
</template>
{{ tag.displayName }}
</NcButton>
<span v-if="shownTags.length === 0 && !loading">
{{ t('systemtags', 'No tags available') }}
</span>
</div>
</template>

<script setup lang="ts">
import type { TagsFilter } from '../files_filters/TagsFilter.ts'
import type { TagWithId } from '../types.ts'

import { mdiTagOutline } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { fetchTags } from '../services/api.ts'

const props = defineProps<{
filter: TagsFilter
}>()

const loading = ref(true)
const searchQuery = ref('')
const availableTags = ref<TagWithId[]>([])
const selectedTags = ref<TagWithId[]>([...props.filter.selectedTags])

watch(selectedTags, () => {
props.filter.setTags(selectedTags.value.length > 0 ? [...selectedTags.value] : undefined)
})

onMounted(async () => {
try {
const tags = await fetchTags()
availableTags.value = tags.filter((tag) => tag.userVisible)
} finally {
loading.value = false
}
props.filter.addEventListener('reset', resetFilter)
props.filter.addEventListener('deselect', onDeselect)
})

onUnmounted(() => {
props.filter.removeEventListener('reset', resetFilter)
props.filter.removeEventListener('deselect', onDeselect)
})

const shownTags = computed(() => {
if (!searchQuery.value) {
return availableTags.value
}
const query = searchQuery.value.toLocaleLowerCase()
return availableTags.value.filter((tag) => tag.displayName.toLocaleLowerCase().includes(query))
})

/**
* Check if a tag is currently selected
*
* @param tag The tag to check
*/
function isSelected(tag: TagWithId): boolean {
return selectedTags.value.some((t) => t.id === tag.id)
}

/**
* Toggle a tag from the selected list
*
* @param tag The tag to toggle
* @param selected Whether the tag should be selected
*/
function toggleTag(tag: TagWithId, selected: boolean) {
selectedTags.value = selectedTags.value.filter((t) => t.id !== tag.id)
if (selected) {
selectedTags.value = [...selectedTags.value, tag]
}
}

/**
* Reset selected tags (triggered by filter reset event)
*/
function resetFilter() {
selectedTags.value = []
}

/**
* Remove a single tag from selected (triggered by chip removal)
*
* @param event The deselect custom event carrying the tag ID
*/
function onDeselect(event: CustomEvent<number>) {
selectedTags.value = selectedTags.value.filter((t) => t.id !== event.detail)
}
</script>
74 changes: 74 additions & 0 deletions apps/systemtags/src/files_filters/TagsFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IFileListFilterChip, IFileListFilterWithUi, INode } from '@nextcloud/files'
import type { TagWithId } from '../types.ts'

import svgTagOutline from '@mdi/svg/svg/tag-outline.svg?raw'
import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { defineCustomElement } from 'vue'
import FileListFilterTagsCE from '../components/FileListFilterTags.vue'
import { getNodeSystemTags } from '../utils.ts'

const tagName = 'systemtags-file-list-filter-tags'

class TagsFilter extends FileListFilter implements IFileListFilterWithUi {
#selectedTags: TagWithId[] = []

public readonly displayName = t('systemtags', 'Tags')
public readonly iconSvgInline = svgTagOutline
public readonly tagName = tagName

constructor() {
super('systemtags:tags', 75)
}

public filter(nodes: INode[]): INode[] {
if (this.#selectedTags.length === 0) {
return nodes
}

const selectedNames = this.#selectedTags.map((tag) => tag.displayName)
return nodes.filter((node) => {
const nodeTags = getNodeSystemTags(node)
return selectedNames.some((name) => nodeTags.includes(name))
})
}

public reset(): void {
this.dispatchEvent(new CustomEvent('reset'))
}

public get selectedTags(): TagWithId[] {
return this.#selectedTags
}

public setTags(tags?: TagWithId[]): void {
this.#selectedTags = tags ?? []
this.filterUpdated()

const chips: IFileListFilterChip[] = this.#selectedTags.map((tag) => ({
icon: svgTagOutline,
text: tag.displayName,
onclick: () => {
this.dispatchEvent(new CustomEvent('deselect', { detail: tag.id }))
this.setTags(this.#selectedTags.filter((t) => t.id !== tag.id))
},
}))
this.updateChips(chips)
}
}

export type { TagsFilter }

/**
* Register the file list filter by system tags
*/
export function registerTagsFilter() {
const TagsFilterElement = defineCustomElement(FileListFilterTagsCE, { shadowRoot: false })
customElements.define(tagName, TagsFilterElement)
registerFileListFilter(new TagsFilter())
}
2 changes: 2 additions & 0 deletions apps/systemtags/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { action as bulkSystemTagsAction } from './files_actions/bulkSystemTagsAc
import { registerFileSidebarAction } from './files_actions/filesSidebarAction.ts'
import { action as inlineSystemTagsAction } from './files_actions/inlineSystemTagsAction.ts'
import { action as openInFilesAction } from './files_actions/openInFilesAction.ts'
import { registerTagsFilter } from './files_filters/TagsFilter.ts'
import { registerSystemTagsView } from './files_views/systemtagsView.ts'

registerDavProperty('nc:system-tags')
Expand All @@ -18,3 +19,4 @@ registerFileAction(openInFilesAction)

registerSystemTagsView()
registerFileSidebarAction()
registerTagsFilter()
Loading