diff --git a/changelog/unreleased/enhancement-search-tag-filter b/changelog/unreleased/enhancement-search-tag-filter
new file mode 100644
index 00000000000..c254cabaedd
--- /dev/null
+++ b/changelog/unreleased/enhancement-search-tag-filter
@@ -0,0 +1,6 @@
+Enhancement: Search tag filter
+
+The search result page now has a tag filter which can be used to filter the displayed search result by tags.
+
+https://github.com/owncloud/web/pull/9044
+https://github.com/owncloud/web/issues/9054
diff --git a/packages/web-app-files/src/components/FilesList/ResourceTable.vue b/packages/web-app-files/src/components/FilesList/ResourceTable.vue
index 530b06a683a..72174602f8a 100644
--- a/packages/web-app-files/src/components/FilesList/ResourceTable.vue
+++ b/packages/web-app-files/src/components/FilesList/ResourceTable.vue
@@ -632,9 +632,10 @@ export default defineComponent({
shouldDisplayThumbnails(item) {
return this.areThumbnailsDisplayed && !isResourceTxtFileAlmostEmpty(item)
},
- getTagLink(tag) {
+ getTagLink(tag: string) {
+ const currentTerm = unref(this.$router.currentRoute).query?.term
return createLocationCommon('files-common-search', {
- query: { term: `Tags:"${tag}"`, provider: 'files.sdk' }
+ query: { provider: 'files.sdk', q_tags: tag, ...(currentTerm && { term: currentTerm }) }
})
},
getTagComponentAttrs(tag) {
diff --git a/packages/web-app-files/src/components/Search/List.vue b/packages/web-app-files/src/components/Search/List.vue
index c31c0ba814c..ab545f33654 100644
--- a/packages/web-app-files/src/components/Search/List.vue
+++ b/packages/web-app-files/src/components/Search/List.vue
@@ -2,6 +2,28 @@
+
{
+ return queryItemAsString(unref(searchTermQuery))
+ })
+
+ const availableTags = ref([])
+ const tagFilter = ref()
+ const tagParam = useRouteQuery('q_tags')
+ const loadAvailableTagsTask = useTask(function* () {
+ const {
+ data: { value: tags = [] }
+ } = yield clientService.graphAuthenticated.tags.getTags()
+ availableTags.value = [...tags.map((t) => ({ id: t, label: t }))]
+ })
onBeforeRouteLeave(() => {
eventBus.publish('app.search.term.clear')
@@ -136,11 +194,51 @@ export default defineComponent({
return store.getters['runtime/spaces/spaces'].find((space) => space.id === resource.storageId)
}
+ const buildSearchTerm = (manuallyUpdateFilterChip = false) => {
+ let term = unref(searchTerm)
+
+ const tags = queryItemAsString(unref(tagParam))
+ if (tags) {
+ const foo = tags.split('+')?.join(',') || ''
+ term += ` Tags:"${unref(foo)}"`
+ if (manuallyUpdateFilterChip && unref(tagFilter)) {
+ /**
+ * Handles edge cases where a filter is not being applied via the filter directly,
+ * e.g. when clicking on a tag in the files list.
+ * We need to manually update the selected items in the ItemFilter component because normally
+ * it only does this on mount or when interacting with the filter directly.
+ */
+ ;(unref(tagFilter) as any).setSelectedItemsBasedOnQuery()
+ }
+ }
+
+ return term
+ }
+
+ onMounted(async () => {
+ await loadAvailableTagsTask.perform()
+ emit('search', buildSearchTerm())
+ })
+
+ watch(
+ () => unref(route).query,
+ (newVal, oldVal) => {
+ const isChange = newVal?.term !== oldVal?.term || newVal?.q_tags !== oldVal?.q_tags
+ if (isChange && isLocationCommonActive(router, 'files-common-search')) {
+ emit('search', buildSearchTerm(true))
+ }
+ },
+ { deep: true }
+ )
+
return {
...useFileActions({ store }),
...useResourcesViewDefaults(),
+ loadAvailableTagsTask,
fileListHeaderY,
- getSpace
+ getSpace,
+ availableTags,
+ tagFilter
}
},
computed: {
@@ -224,3 +322,8 @@ export default defineComponent({
}
})
+
diff --git a/packages/web-app-files/src/router/common.ts b/packages/web-app-files/src/router/common.ts
index 54a31959d52..b5c13aed739 100644
--- a/packages/web-app-files/src/router/common.ts
+++ b/packages/web-app-files/src/router/common.ts
@@ -27,7 +27,7 @@ export const buildRoutes = (components: RouteComponents): RouteRecordRaw[] => [
meta: {
authContext: 'user',
title: $gettext('Search results'),
- contextQueryItems: ['term', 'provider']
+ contextQueryItems: ['term', 'provider', 'q_tags']
}
}
]
diff --git a/packages/web-app-files/tests/unit/components/Search/List.spec.ts b/packages/web-app-files/tests/unit/components/Search/List.spec.ts
index f16148f9daf..d07bfcb795e 100644
--- a/packages/web-app-files/tests/unit/components/Search/List.spec.ts
+++ b/packages/web-app-files/tests/unit/components/Search/List.spec.ts
@@ -1,176 +1,91 @@
import { describe } from '@jest/globals'
-// import List from 'web-app-files/src/components/Search/List.vue'
-//
-// const stubs = {
-// 'app-bar': true,
-// 'no-content-message': false,
-// 'resource-table': false,
-// pagination: true,
-// 'list-info': true
-// }
-//
-// const selectors = {
-// noContentMessage: '.files-empty',
-// filesTable: '.files-table',
-// pagination: 'pagination-stub',
-// listInfo: 'list-info-stub'
-// }
-//
-// const user = { id: 'test' }
-//
+import { shallowMount } from '@vue/test-utils'
+import List from 'web-app-files/src/components/Search/List.vue'
+import { useResourcesViewDefaults } from 'web-app-files/src/composables'
+import { useResourcesViewDefaultsMock } from 'web-app-files/tests/mocks/useResourcesViewDefaultsMock'
+import {
+ createStore,
+ defaultComponentMocks,
+ defaultPlugins,
+ defaultStoreMockOptions,
+ mockAxiosResolve
+} from 'web-test-helpers/src'
+import { queryItemAsString } from 'web-pkg'
+import { ref } from 'vue'
+import { Resource } from 'web-client/src'
+import { mock } from 'jest-mock-extended'
+
+jest.mock('web-app-files/src/composables')
+jest.mock('web-pkg/src/composables/appDefaults')
+
+const selectors = {
+ noContentMessageStub: 'no-content-message-stub',
+ resourceTableStub: 'resource-table-stub',
+ tagFilter: '.files-search-filter-tags'
+}
+
describe('List component', () => {
- it.todo('Refactor tests')
- // afterEach(() => {
- // jest.clearAllMocks()
- // })
- //
- // describe.each(['no search term is entered', 'no resource is found'])('when %s', (message) => {
- // let wrapper
- // beforeEach(() => {
- // if (message === 'no search term is entered') {
- // wrapper = getWrapper()
- // } else {
- // wrapper = getWrapper('epsum.txt')
- // }
- // })
- //
- // it('should show no-content-message component', () => {
- // const noContentMessage = wrapper.find(selectors.noContentMessage)
- //
- // expect(noContentMessage.exists()).toBeTruthy()
- // expect(wrapper.html()).toMatchSnapshot()
- // })
- // it('should not show files table', () => {
- // const filesTable = wrapper.find(selectors.filesTable)
- // const listInfo = wrapper.find(selectors.listInfo)
- //
- // expect(filesTable.exists()).toBeFalsy()
- // expect(listInfo.exists()).toBeFalsy()
- // })
- // })
- //
- // describe('when resources are found', () => {
- // const spyTriggerDefaultAction = jest
- // .spyOn(List.mixins[0].methods, '$_fileActions_triggerDefaultAction')
- // .mockImplementation()
- // const spyRowMounted = jest.spyOn(List.methods, 'rowMounted')
- //
- // let wrapper
- // beforeEach(() => {
- // wrapper = getWrapper('lorem', files)
- // })
- //
- // it('should not show no-content-message component', () => {
- // const noContentMessage = wrapper.find(selectors.noContentMessage)
- //
- // expect(noContentMessage.exists()).toBeFalsy()
- // })
- // it('should set correct props on list-info component', () => {
- // const listInfo = wrapper.find(selectors.listInfo)
- //
- // expect(listInfo.exists()).toBeTruthy()
- // expect(listInfo.props().files).toEqual(files.length)
- // expect(listInfo.props().folders).toEqual(0)
- // expect(listInfo.props().size).toEqual(getTotalSize(files))
- // })
- // it('should trigger the default action when a "fileClick" event gets emitted', async () => {
- // const filesTable = wrapper.find(selectors.filesTable)
- //
- // expect(spyTriggerDefaultAction).toHaveBeenCalledTimes(0)
- //
- // await filesTable.trigger('fileClick')
- //
- // expect(spyTriggerDefaultAction).toHaveBeenCalledTimes(1)
- // })
- // it('should lazily load previews when a "rowMounted" event gets emitted', () => {
- // expect(spyRowMounted).toHaveBeenCalledTimes(files.length)
- // })
- // })
+ it('should render no-content-message if no resources found', () => {
+ const { wrapper } = getWrapper()
+ expect(wrapper.find(selectors.noContentMessageStub).exists()).toBeTruthy()
+ })
+ it('should render resource table if resources found', () => {
+ const { wrapper } = getWrapper({ resources: [mock()] })
+ expect(wrapper.find(selectors.resourceTableStub).exists()).toBeTruthy()
+ })
+ it('should emit search event on mount', async () => {
+ const { wrapper } = getWrapper()
+ await wrapper.vm.loadAvailableTagsTask.last
+ expect(wrapper.emitted('search').length).toBeGreaterThan(0)
+ })
+ describe('filter', () => {
+ describe('tags', () => {
+ it('should show all available tags', async () => {
+ const tag = 'tag1'
+ const { wrapper } = getWrapper({ availableTags: [tag] })
+ await wrapper.vm.loadAvailableTagsTask.last
+ expect(wrapper.find(selectors.tagFilter).exists()).toBeTruthy()
+ expect(wrapper.findComponent(selectors.tagFilter).props('items')).toEqual([
+ { label: tag, id: tag }
+ ])
+ })
+ it('should set initial filter when tags are given via query param', async () => {
+ const tagFilterQuery = 'tag1'
+ const { wrapper } = getWrapper({
+ availableTags: ['tag1'],
+ tagFilterQuery
+ })
+ await wrapper.vm.loadAvailableTagsTask.last
+ expect(wrapper.emitted('search')[0][0]).toEqual(tagFilterQuery)
+ })
+ })
+ })
})
-//
-// function getWrapper(searchTerm = '', files = []) {
-// return mount(List, {
-// propsData: {
-// searchResult: {
-// totalResults: 100,
-// values: getSearchResults(files)
-// }
-// },
-// store: createStore(files),
-// stubs,
-// mock: {
-// webdav: {
-// getFileInfo: jest.fn()
-// }
-// }
-// })
-// }
-//
-// function createStore(activeFiles) {
-// return createStore({
-// getters: {
-// configuration: () => ({
-// options: {
-// disablePreviews: true
-// }
-// }),
-// user: () => user
-// },
-// modules: {
-// Files: {
-// namespaced: true,
-// state: {
-// selectedIds: []
-// },
-// getters: {
-// activeFiles: () => activeFiles,
-// selectedFiles: () => [],
-// totalFilesCount: () => ({ files: activeFiles.length, folders: 0 }),
-// totalFilesSize: () => getTotalSize(activeFiles),
-// currentFolder: () => {
-// return {
-// path: '',
-// canCreate() {
-// return false
-// }
-// }
-// }
-// },
-// mutations: {
-// CLEAR_CURRENT_FILES_LIST: jest.fn(),
-// CLEAR_FILES_SEARCHED: jest.fn(),
-// LOAD_FILES: jest.fn()
-// }
-// }
-// }
-// })
-// }
-//
-// function getSearchResults(files) {
-// return files.map((file) => ({ data: file, id: file.id }))
-// }
-//
-// function getTotalSize(files) {
-// return files.reduce((total, file) => total + file.size, 0)
-// }
-//
-// const files = [
-// {
-// id: '1',
-// path: 'lorem.txt',
-// size: 100
-// },
-// {
-// id: '2',
-// path: 'lorem.pdf',
-// size: 50
-// }
-// ].map((file) => {
-// return {
-// ...file,
-// canDownload: () => true,
-// canBeDeleted: () => true,
-// isReceivedShare: () => false,
-// isMounted: () => false
-// }
-// })
+
+function getWrapper({ availableTags = [], resources = [], tagFilterQuery = null } = {}) {
+ jest.mocked(queryItemAsString).mockImplementationOnce(() => tagFilterQuery)
+
+ const resourcesViewDetailsMock = useResourcesViewDefaultsMock({
+ paginatedResources: ref(resources)
+ })
+ jest.mocked(useResourcesViewDefaults).mockImplementation(() => resourcesViewDetailsMock)
+
+ const mocks = defaultComponentMocks()
+ mocks.$clientService.graphAuthenticated.tags.getTags.mockReturnValue(
+ mockAxiosResolve({ value: availableTags })
+ )
+ const storeOptions = defaultStoreMockOptions
+ const store = createStore(storeOptions)
+ return {
+ mocks,
+ wrapper: shallowMount(List, {
+ global: {
+ mocks,
+ stubs: {
+ FilesViewWrapper: false
+ },
+ plugins: [...defaultPlugins(), store]
+ }
+ })
+ }
+}
diff --git a/packages/web-app-search/src/portals/SearchBar.vue b/packages/web-app-search/src/portals/SearchBar.vue
index 72a09a46783..769d67bbf12 100644
--- a/packages/web-app-search/src/portals/SearchBar.vue
+++ b/packages/web-app-search/src/portals/SearchBar.vue
@@ -272,9 +272,15 @@ export default defineComponent({
this.optionsDrop.hide()
if (this.term && this.activePreviewIndex === null) {
+ const currentQuery = unref(this.$router.currentRoute).query
+
this.$router.push(
createLocationCommon('files-common-search', {
- query: { term: this.term, provider: 'files.sdk' }
+ query: {
+ ...(currentQuery && { ...currentQuery }),
+ term: this.term,
+ provider: 'files.sdk'
+ }
})
)
}
@@ -344,8 +350,10 @@ export default defineComponent({
return this.searchResults.find(({ providerId }) => providerId === provider.id)?.result
},
getMoreResultsLinkForProvider(provider) {
+ const currentQuery = unref(this.$router.currentRoute).query
+
return createLocationCommon('files-common-search', {
- query: { term: this.term, provider: provider.id }
+ query: { ...(currentQuery && { ...currentQuery }), term: this.term, provider: provider.id }
})
},
getMoreResultsDetailsTextForProvider(provider) {
diff --git a/packages/web-app-search/src/views/List.vue b/packages/web-app-search/src/views/List.vue
index ae57927ab99..701ea5bff22 100644
--- a/packages/web-app-search/src/views/List.vue
+++ b/packages/web-app-search/src/views/List.vue
@@ -1,13 +1,16 @@
-
+