Skip to content

Commit 8374fa4

Browse files
committed
implement tag filter
Assisted-by: Copilot:claude-sonnet-4-6 Signed-off-by: Tomasz Trillò <tomasz.trillo@dataport.de>
1 parent 368f338 commit 8374fa4

3 files changed

Lines changed: 193 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
<template>
6+
<div>
7+
<NcTextField
8+
v-if="availableTags.length > 5"
9+
v-model="searchQuery"
10+
type="search"
11+
:label="t('systemtags', 'Search tags')" />
12+
<NcButton
13+
v-for="tag of shownTags"
14+
:key="tag.id"
15+
alignment="start"
16+
:pressed="isSelected(tag)"
17+
variant="tertiary"
18+
wide
19+
@update:pressed="toggleTag(tag, $event)">
20+
<template #icon>
21+
<NcIconSvgWrapper :path="mdiTagOutline" />
22+
</template>
23+
{{ tag.displayName }}
24+
</NcButton>
25+
<span v-if="shownTags.length === 0 && !loading">
26+
{{ t('systemtags', 'No tags available') }}
27+
</span>
28+
</div>
29+
</template>
30+
31+
<script setup lang="ts">
32+
import type { TagsFilter } from '../files_filters/TagsFilter.ts'
33+
import type { TagWithId } from '../types.ts'
34+
35+
import { mdiTagOutline } from '@mdi/js'
36+
import { t } from '@nextcloud/l10n'
37+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
38+
import NcButton from '@nextcloud/vue/components/NcButton'
39+
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
40+
import NcTextField from '@nextcloud/vue/components/NcTextField'
41+
import { fetchTags } from '../services/api.ts'
42+
43+
const props = defineProps<{
44+
filter: TagsFilter
45+
}>()
46+
47+
const loading = ref(true)
48+
const searchQuery = ref('')
49+
const availableTags = ref<TagWithId[]>([])
50+
const selectedTags = ref<TagWithId[]>([...props.filter.selectedTags])
51+
52+
watch(selectedTags, () => {
53+
props.filter.setTags(selectedTags.value.length > 0 ? [...selectedTags.value] : undefined)
54+
})
55+
56+
onMounted(async () => {
57+
try {
58+
const tags = await fetchTags()
59+
availableTags.value = tags.filter((tag) => tag.userVisible)
60+
} finally {
61+
loading.value = false
62+
}
63+
props.filter.addEventListener('reset', resetFilter)
64+
props.filter.addEventListener('deselect', onDeselect)
65+
})
66+
67+
onUnmounted(() => {
68+
props.filter.removeEventListener('reset', resetFilter)
69+
props.filter.removeEventListener('deselect', onDeselect)
70+
})
71+
72+
const shownTags = computed(() => {
73+
if (!searchQuery.value) {
74+
return availableTags.value
75+
}
76+
const query = searchQuery.value.toLocaleLowerCase()
77+
return availableTags.value.filter((tag) => tag.displayName.toLocaleLowerCase().includes(query))
78+
})
79+
80+
/**
81+
* Check if a tag is currently selected
82+
*
83+
* @param tag The tag to check
84+
*/
85+
function isSelected(tag: TagWithId): boolean {
86+
return selectedTags.value.some((t) => t.id === tag.id)
87+
}
88+
89+
/**
90+
* Toggle a tag from the selected list
91+
*
92+
* @param tag The tag to toggle
93+
* @param selected Whether the tag should be selected
94+
*/
95+
function toggleTag(tag: TagWithId, selected: boolean) {
96+
selectedTags.value = selectedTags.value.filter((t) => t.id !== tag.id)
97+
if (selected) {
98+
selectedTags.value = [...selectedTags.value, tag]
99+
}
100+
}
101+
102+
/**
103+
* Reset selected tags (triggered by filter reset event)
104+
*/
105+
function resetFilter() {
106+
selectedTags.value = []
107+
}
108+
109+
/**
110+
* Remove a single tag from selected (triggered by chip removal)
111+
*
112+
* @param event The deselect custom event carrying the tag ID
113+
*/
114+
function onDeselect(event: CustomEvent<number>) {
115+
selectedTags.value = selectedTags.value.filter((t) => t.id !== event.detail)
116+
}
117+
</script>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { IFileListFilterChip, IFileListFilterWithUi, INode } from '@nextcloud/files'
7+
import type { TagWithId } from '../types.ts'
8+
9+
import svgTagOutline from '@mdi/svg/svg/tag-outline.svg?raw'
10+
import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
11+
import { t } from '@nextcloud/l10n'
12+
import { defineCustomElement } from 'vue'
13+
import FileListFilterTagsCE from '../components/FileListFilterTags.vue'
14+
import { getNodeSystemTags } from '../utils.ts'
15+
16+
const tagName = 'systemtags-file-list-filter-tags'
17+
18+
class TagsFilter extends FileListFilter implements IFileListFilterWithUi {
19+
#selectedTags: TagWithId[] = []
20+
21+
public readonly displayName = t('systemtags', 'Tags')
22+
public readonly iconSvgInline = svgTagOutline
23+
public readonly tagName = tagName
24+
25+
constructor() {
26+
super('systemtags:tags', 75)
27+
}
28+
29+
public filter(nodes: INode[]): INode[] {
30+
if (this.#selectedTags.length === 0) {
31+
return nodes
32+
}
33+
34+
const selectedNames = this.#selectedTags.map((tag) => tag.displayName)
35+
return nodes.filter((node) => {
36+
const nodeTags = getNodeSystemTags(node)
37+
return selectedNames.some((name) => nodeTags.includes(name))
38+
})
39+
}
40+
41+
public reset(): void {
42+
this.dispatchEvent(new CustomEvent('reset'))
43+
}
44+
45+
public get selectedTags(): TagWithId[] {
46+
return this.#selectedTags
47+
}
48+
49+
public setTags(tags?: TagWithId[]): void {
50+
this.#selectedTags = tags ?? []
51+
this.filterUpdated()
52+
53+
const chips: IFileListFilterChip[] = this.#selectedTags.map((tag) => ({
54+
icon: svgTagOutline,
55+
text: tag.displayName,
56+
onclick: () => {
57+
this.dispatchEvent(new CustomEvent('deselect', { detail: tag.id }))
58+
this.setTags(this.#selectedTags.filter((t) => t.id !== tag.id))
59+
},
60+
}))
61+
this.updateChips(chips)
62+
}
63+
}
64+
65+
export type { TagsFilter }
66+
67+
/**
68+
* Register the file list filter by system tags
69+
*/
70+
export function registerTagsFilter() {
71+
const TagsFilterElement = defineCustomElement(FileListFilterTagsCE, { shadowRoot: false })
72+
customElements.define(tagName, TagsFilterElement)
73+
registerFileListFilter(new TagsFilter())
74+
}

apps/systemtags/src/init.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { action as bulkSystemTagsAction } from './files_actions/bulkSystemTagsAc
99
import { registerFileSidebarAction } from './files_actions/filesSidebarAction.ts'
1010
import { action as inlineSystemTagsAction } from './files_actions/inlineSystemTagsAction.ts'
1111
import { action as openInFilesAction } from './files_actions/openInFilesAction.ts'
12+
import { registerTagsFilter } from './files_filters/TagsFilter.ts'
1213
import { registerSystemTagsView } from './files_views/systemtagsView.ts'
1314

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

1920
registerSystemTagsView()
2021
registerFileSidebarAction()
22+
registerTagsFilter()

0 commit comments

Comments
 (0)