diff --git a/changelog/unreleased/enhancement-search-term-linking b/changelog/unreleased/enhancement-search-term-linking new file mode 100644 index 00000000000..0dd8771ba76 --- /dev/null +++ b/changelog/unreleased/enhancement-search-term-linking @@ -0,0 +1,16 @@ +Enhancement: Search query term linking + +We've added the option to search for multiple terms with the same type, +at the moment only the tag search benefits from it. + +This makes it possible to search for multiple resources with different tags in one query. +The UI now empowers the user to perform advanced searches like: + +* all resources with the tags `tag1` OR `tag2` +* all resources with the tags `tag1` OR `tag2` AND containing text `content` + +as a rule of thumb, if a property appears multiple times (like `tag1` OR `tag2`) +the search combines the query with an `OR` and different keys are linked with an `AND`. + +https://github.com/owncloud/web/pull/9854 +https://github.com/owncloud/web/issues/9829 diff --git a/packages/web-app-files/src/components/Search/List.vue b/packages/web-app-files/src/components/Search/List.vue index ec489f80daa..e5d584c6015 100644 --- a/packages/web-app-files/src/components/Search/List.vue +++ b/packages/web-app-files/src/components/Search/List.vue @@ -10,12 +10,12 @@ <item-filter v-if="availableTags.length" ref="tagFilter" + :allow-multiple="true" :filter-label="$gettext('Tags')" :filterable-attributes="['label']" :items="availableTags" :option-filter-label="$gettext('Filter tags')" :show-option-filter="true" - :close-on-click="true" class="files-search-filter-tags oc-mr-s" display-name-attribute="label" filter-name="tags" @@ -136,7 +136,17 @@ import { debounce } from 'lodash-es' import { mapMutations, mapGetters, mapActions } from 'vuex' import { useGettext } from 'vue3-gettext' import { AppBar } from '@ownclouders/web-pkg' -import { computed, defineComponent, nextTick, onMounted, ref, unref, VNodeRef, watch } from 'vue' +import { + computed, + defineComponent, + nextTick, + onMounted, + Ref, + ref, + unref, + VNodeRef, + watch +} from 'vue' import ListInfo from '../FilesList/ListInfo.vue' import { Pagination } from '@ownclouders/web-pkg' import { useFileActions } from '@ownclouders/web-pkg' @@ -278,57 +288,71 @@ export default defineComponent({ ) const buildSearchTerm = (manuallyUpdateFilterChip = false) => { - let query = '' - const add = (k: string, v: string) => { - query = (query + ` ${k}:${v}`).trimStart() - } + const query = {} const humanSearchTerm = unref(searchTerm) const isContentOnlySearch = queryItemAsString(unref(fullTextParam)) == 'true' if (isContentOnlySearch && !!humanSearchTerm) { - add('content', `"${humanSearchTerm}"`) + query['content'] = `"${humanSearchTerm}"` } else if (!!humanSearchTerm) { - add('name', `"*${humanSearchTerm}*"`) + query['name'] = `"*${humanSearchTerm}*"` } const humanScopeQuery = unref(scopeQuery) const isScopedSearch = unref(doUseScope) === 'true' if (isScopedSearch && humanScopeQuery) { - add('scope', `${humanScopeQuery}`) + query['scope'] = `${humanScopeQuery}` } - const humanTagsParams = queryItemAsString(unref(tagParam)) - if (humanTagsParams) { - add('tag', `"${humanTagsParams}"`) - - if (manuallyUpdateFilterChip && unref(tagFilter)) { + const updateFilter = (v: Ref) => { + if (manuallyUpdateFilterChip && unref(v)) { /** * 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() + ;(unref(v) as any).setSelectedItemsBasedOnQuery() } } + const humanTagsParams = queryItemAsString(unref(tagParam)) + if (humanTagsParams) { + query['tag'] = humanTagsParams.split('+').map((t) => `"${t}"`) + updateFilter(tagFilter) + } + const lastModifiedParams = queryItemAsString(unref(lastModifiedParam)) if (lastModifiedParams) { - add('mtime', `"${lastModifiedParams}"`) - - if (manuallyUpdateFilterChip && unref(lastModifiedFilter)) { - /** - * 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(lastModifiedFilter) as any).setSelectedItemsBasedOnQuery() - } + query['mtime'] = `"${lastModifiedParams}"` + updateFilter(lastModifiedFilter) } - return query + return ( + // By definition (KQL spec) OR, AND or (GROUP) is implicit for simple cases where + // different or identical keys are part of the query. + // + // We list these operators for the following reasons nevertheless explicit: + // * request readability + // * code readability + // * complex cases readability + Object.keys(query) + .reduce((acc, prop) => { + const isArrayValue = Array.isArray(query[prop]) + + if (!isArrayValue) { + acc.push(`${prop}:${query[prop]}`) + } + + if (isArrayValue) { + acc.push(`${prop}:(${query[prop].join(' OR ')})`) + } + + return acc + }, []) + .join(' AND ') + ) } const breadcrumbs = computed(() => { 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 0ac2cff933c..18f674856a7 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 @@ -79,15 +79,15 @@ describe('List component', () => { }) it('should set initial filter when tags are given via query param', async () => { const searchTerm = 'term' - const tagFilterQuery = 'tag1' + const availableTags = ['tag1', 'tag2'] const { wrapper } = getWrapper({ - availableTags: [tagFilterQuery], + availableTags, searchTerm, - tagFilterQuery + tagFilterQuery: availableTags.join('+') }) await wrapper.vm.loadAvailableTagsTask.last expect(wrapper.emitted('search')[0][0]).toEqual( - `name:"*${searchTerm}*" tag:"${tagFilterQuery}"` + `name:"*${searchTerm}*" AND tag:("${availableTags[0]}" OR "${availableTags[1]}")` ) }) }) @@ -140,7 +140,7 @@ describe('List component', () => { }) await wrapper.vm.loadAvailableTagsTask.last expect(wrapper.emitted('search')[0][0]).toEqual( - `name:"*${searchTerm}*" mtime:"${lastModifiedFilterQuery}"` + `name:"*${searchTerm}*" AND mtime:"${lastModifiedFilterQuery}"` ) }) })