Skip to content

Commit 58b1f48

Browse files
authored
Merge pull request #62694 from Dataport/feature/file-tags
feat(systemtags): implement tag filter
2 parents 7f6b560 + 9dda7da commit 58b1f48

312 files changed

Lines changed: 458 additions & 265 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import{a as t}from"./index-B_OxHYNM.chunk.mjs";import{t as m}from"./translation-DoG5ZELJ-Cnt4vXgN.chunk.mjs";import{C as e,a as p}from"./CommentView-DKkrwe-z.chunk.mjs";import{l as i}from"./activity-CAMqbip4.chunk.mjs";import{b as a,r as s,o as n,c,m as u}from"./Web-YxOIih-q.chunk.mjs";import{_ as l}from"./public-C1mLBHT3.chunk.mjs";import"./mdi-DaKqoE8u.chunk.mjs";import"./NcModal-DiDvgcU8-D2f8VN_9.chunk.mjs";import"./logger-D3RVzcfQ-DRPkuAPJ.chunk.mjs";import"./createElementId-DhjFt1I9-CeyNLNUQ.chunk.mjs";import"./index-kP1pdrFW.chunk.mjs";import"./Check-DJVOsq9N.chunk.mjs";import"./TrashCanOutline-zBEOFG6G.chunk.mjs";import"./PencilOutline-DUsa_r-m.chunk.mjs";import"./NcActionSeparator-B9pNQaji-B5GIovH-.chunk.mjs";import"./NcAvatar-1KxMUN7V-DtAdmWOQ.chunk.mjs";import"./index-C5ZxdJOD.chunk.mjs";import"./util-C9Hc1fff.chunk.mjs";import"./ArrowRight-Ds1zL9Fh.chunk.mjs";import"./colors-Cv9F-jWS-BbMd5FbR.chunk.mjs";import"./NcUserStatusIcon-BF5OEQFU-BbBQ45O1.chunk.mjs";import"./NcActionLink-BFiaYt9A-B1MbKmUJ.chunk.mjs";import"./NcDateTime.vue_vue_type_script_setup_true_lang-BJuPH7S7-B_Nx_tHj.chunk.mjs";import"./NcActionText-CQ9qwJ0p-DjPadvh3.chunk.mjs";import"./NcUserBubble-BXR7vT0V-CeirtlO9.chunk.mjs";import"./GetComments-CnP-ZrGj.chunk.mjs";import"./index-BRj61ap9.chunk.mjs";import"./dav-BAGD64kD.chunk.mjs";import"./externalStorageUtils-CQ1qe2np.chunk.mjs";const d=a({components:{CommentEntry:p},mixins:[e],props:{reloadCallback:{type:Function,required:!0}},methods:{onNewComment(){try{this.reloadCallback()}catch(o){t(m("comments","Could not reload comments")),i.error("Could not reload comments",{error:o})}}}});function C(o,f,y,b,w,N){const r=s("CommentEntry");return n(),c(r,u(o.editorData,{autoComplete:o.autoComplete,resourceType:o.resourceType,editor:!0,userData:o.userData,resourceId:o.resourceId,class:"comments-action",onNew:o.onNewComment}),null,16,["autoComplete","resourceType","userData","resourceId","onNew"])}const W=l(d,[["render",C],["__scopeId","data-v-099b6b12"]]);export{W as default};
2+
//# sourceMappingURL=ActivityCommentAction-BvMB4ZR0.chunk.mjs.map
File renamed without changes.

dist/ActivityCommentAction-eCmL1yLx.chunk.mjs.map renamed to dist/ActivityCommentAction-BvMB4ZR0.chunk.mjs.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/ActivityCommentAction-eCmL1yLx.chunk.mjs.map.license renamed to dist/ActivityCommentAction-BvMB4ZR0.chunk.mjs.map.license

File renamed without changes.

dist/ActivityCommentAction-eCmL1yLx.chunk.mjs

Lines changed: 0 additions & 2 deletions
This file was deleted.

dist/ActivityCommentEntry-4ldbyMwf.chunk.mjs

Lines changed: 0 additions & 2 deletions
This file was deleted.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import{t as s}from"./translation-DoG5ZELJ-Cnt4vXgN.chunk.mjs";import{C as p,a as i}from"./CommentView-DKkrwe-z.chunk.mjs";import{_ as a}from"./public-C1mLBHT3.chunk.mjs";import{r as n,o as c,c as u,m as d}from"./Web-YxOIih-q.chunk.mjs";import"./index-kP1pdrFW.chunk.mjs";import"./PencilOutline-DUsa_r-m.chunk.mjs";import"./logger-D3RVzcfQ-DRPkuAPJ.chunk.mjs";import"./createElementId-DhjFt1I9-CeyNLNUQ.chunk.mjs";import"./NcModal-DiDvgcU8-D2f8VN_9.chunk.mjs";import"./NcActionSeparator-B9pNQaji-B5GIovH-.chunk.mjs";import"./NcAvatar-1KxMUN7V-DtAdmWOQ.chunk.mjs";import"./index-C5ZxdJOD.chunk.mjs";import"./util-C9Hc1fff.chunk.mjs";import"./ArrowRight-Ds1zL9Fh.chunk.mjs";import"./colors-Cv9F-jWS-BbMd5FbR.chunk.mjs";import"./NcUserStatusIcon-BF5OEQFU-BbBQ45O1.chunk.mjs";import"./NcActionLink-BFiaYt9A-B1MbKmUJ.chunk.mjs";import"./NcDateTime.vue_vue_type_script_setup_true_lang-BJuPH7S7-B_Nx_tHj.chunk.mjs";import"./NcActionText-CQ9qwJ0p-DjPadvh3.chunk.mjs";import"./Check-DJVOsq9N.chunk.mjs";import"./NcUserBubble-BXR7vT0V-CeirtlO9.chunk.mjs";import"./TrashCanOutline-zBEOFG6G.chunk.mjs";import"./index-B_OxHYNM.chunk.mjs";import"./mdi-DaKqoE8u.chunk.mjs";import"./activity-CAMqbip4.chunk.mjs";import"./GetComments-CnP-ZrGj.chunk.mjs";import"./index-BRj61ap9.chunk.mjs";import"./dav-BAGD64kD.chunk.mjs";import"./externalStorageUtils-CQ1qe2np.chunk.mjs";const l={name:"ActivityCommentEntry",components:{CommentEntry:i},mixins:[p],props:{comment:{type:Object,required:!0},reloadCallback:{type:Function,required:!0}},data(){return{commentMessage:""}},watch:{comment(){this.commentMessage=this.comment.props.message}},mounted(){this.commentMessage=this.comment.props.message},methods:{t:s}};function g(t,e,o,y,m,C){const r=n("CommentEntry");return c(),u(r,d({ref:"comment",tag:"li"},o.comment.props,{autoComplete:t.autoComplete,resourceType:t.resourceType,message:m.commentMessage,resourceId:t.resourceId,userData:t.genMentionsData(o.comment.props.mentions),class:"comments-activity",onDelete:e[0]||(e[0]=f=>o.reloadCallback())}),null,16,["autoComplete","resourceType","message","resourceId","userData"])}const U=a(l,[["render",g],["__scopeId","data-v-2d51dbfd"]]);export{U as default};
2+
//# sourceMappingURL=ActivityCommentEntry-lB3BaOmt.chunk.mjs.map

0 commit comments

Comments
 (0)