From 373f04bfd32b96c5b2144a4609bf8e45da800d6b Mon Sep 17 00:00:00 2001 From: Florian Schade Date: Tue, 24 Oct 2023 16:32:05 +0200 Subject: [PATCH 1/4] enhancement: search query term linking --- .../enhancement-search-term-linking | 16 ++++++++ .../src/components/Search/List.vue | 40 ++++++++++++++----- .../tests/unit/components/Search/List.spec.ts | 8 ++-- 3 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 changelog/unreleased/enhancement-search-term-linking 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..fd1467fa865 100644 --- a/packages/web-app-files/src/components/Search/List.vue +++ b/packages/web-app-files/src/components/Search/List.vue @@ -278,29 +278,26 @@ export default defineComponent({ ) const buildSearchTerm = (manuallyUpdateFilterChip = false) => { - let query = '' - const add = (k: string, v: string) => { - query = (query + ` ${k}:${v}`).trimStart() - } + const q = {} const humanSearchTerm = unref(searchTerm) const isContentOnlySearch = queryItemAsString(unref(fullTextParam)) == 'true' if (isContentOnlySearch && !!humanSearchTerm) { - add('content', `"${humanSearchTerm}"`) + q['content'] = `"${humanSearchTerm}"` } else if (!!humanSearchTerm) { - add('name', `"*${humanSearchTerm}*"`) + q['name'] = `"*${humanSearchTerm}*"` } const humanScopeQuery = unref(scopeQuery) const isScopedSearch = unref(doUseScope) === 'true' if (isScopedSearch && humanScopeQuery) { - add('scope', `${humanScopeQuery}`) + q['scope'] = `${humanScopeQuery}` } const humanTagsParams = queryItemAsString(unref(tagParam)) if (humanTagsParams) { - add('tag', `"${humanTagsParams}"`) + q['tag'] = humanTagsParams.split('+').map((t) => `"${t}"`) if (manuallyUpdateFilterChip && unref(tagFilter)) { /** @@ -315,7 +312,7 @@ export default defineComponent({ const lastModifiedParams = queryItemAsString(unref(lastModifiedParam)) if (lastModifiedParams) { - add('mtime', `"${lastModifiedParams}"`) + q['mtime'] = `"${lastModifiedParams}"` if (manuallyUpdateFilterChip && unref(lastModifiedFilter)) { /** @@ -328,7 +325,30 @@ export default defineComponent({ } } - 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(q) + .reduce((acc, k) => { + const isArrayValue = Array.isArray(q[k]) + + if (!isArrayValue) { + acc.push(`${k}:${q[k]}`) + } + + if (isArrayValue) { + acc.push(`${k}:(${q[k].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..1deb5b6d7c3 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]}")` ) }) }) From 4bc897dc7aeb8995f030fcb02df9d636888328fa Mon Sep 17 00:00:00 2001 From: Florian Schade Date: Wed, 25 Oct 2023 12:56:49 +0200 Subject: [PATCH 2/4] enhancement: refactor filter update and make it reusable --- .../src/components/Search/List.vue | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/web-app-files/src/components/Search/List.vue b/packages/web-app-files/src/components/Search/List.vue index fd1467fa865..9477e2d44ec 100644 --- a/packages/web-app-files/src/components/Search/List.vue +++ b/packages/web-app-files/src/components/Search/List.vue @@ -10,6 +10,7 @@ `"${t}"`) - - 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) { + q['tag'] = humanTagsParams.split('+').map((t) => `"${t}"`) + updateFilter(tagFilter) + } + const lastModifiedParams = queryItemAsString(unref(lastModifiedParam)) if (lastModifiedParams) { q['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() - } + updateFilter(lastModifiedFilter) } return ( From ab30f39886f0585554b8aea373834b90841aa4b0 Mon Sep 17 00:00:00 2001 From: Florian Schade Date: Wed, 25 Oct 2023 13:25:46 +0200 Subject: [PATCH 3/4] fix: search list unit test --- .../web-app-files/tests/unit/components/Search/List.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1deb5b6d7c3..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 @@ -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}"` ) }) }) From 5e3c2afd7c2d4b6096c6bf26dc6eeb04c661a185 Mon Sep 17 00:00:00 2001 From: Jannik Stehle Date: Fri, 27 Oct 2023 14:08:45 +0200 Subject: [PATCH 4/4] style: rename variables for better understanding --- .../src/components/Search/List.vue | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/web-app-files/src/components/Search/List.vue b/packages/web-app-files/src/components/Search/List.vue index 9477e2d44ec..e5d584c6015 100644 --- a/packages/web-app-files/src/components/Search/List.vue +++ b/packages/web-app-files/src/components/Search/List.vue @@ -16,7 +16,6 @@ :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" @@ -289,21 +288,21 @@ export default defineComponent({ ) const buildSearchTerm = (manuallyUpdateFilterChip = false) => { - const q = {} + const query = {} const humanSearchTerm = unref(searchTerm) const isContentOnlySearch = queryItemAsString(unref(fullTextParam)) == 'true' if (isContentOnlySearch && !!humanSearchTerm) { - q['content'] = `"${humanSearchTerm}"` + query['content'] = `"${humanSearchTerm}"` } else if (!!humanSearchTerm) { - q['name'] = `"*${humanSearchTerm}*"` + query['name'] = `"*${humanSearchTerm}*"` } const humanScopeQuery = unref(scopeQuery) const isScopedSearch = unref(doUseScope) === 'true' if (isScopedSearch && humanScopeQuery) { - q['scope'] = `${humanScopeQuery}` + query['scope'] = `${humanScopeQuery}` } const updateFilter = (v: Ref) => { @@ -320,13 +319,13 @@ export default defineComponent({ const humanTagsParams = queryItemAsString(unref(tagParam)) if (humanTagsParams) { - q['tag'] = humanTagsParams.split('+').map((t) => `"${t}"`) + query['tag'] = humanTagsParams.split('+').map((t) => `"${t}"`) updateFilter(tagFilter) } const lastModifiedParams = queryItemAsString(unref(lastModifiedParam)) if (lastModifiedParams) { - q['mtime'] = `"${lastModifiedParams}"` + query['mtime'] = `"${lastModifiedParams}"` updateFilter(lastModifiedFilter) } @@ -338,16 +337,16 @@ export default defineComponent({ // * request readability // * code readability // * complex cases readability - Object.keys(q) - .reduce((acc, k) => { - const isArrayValue = Array.isArray(q[k]) + Object.keys(query) + .reduce((acc, prop) => { + const isArrayValue = Array.isArray(query[prop]) if (!isArrayValue) { - acc.push(`${k}:${q[k]}`) + acc.push(`${prop}:${query[prop]}`) } if (isArrayValue) { - acc.push(`${k}:(${q[k].join(' OR ')})`) + acc.push(`${prop}:(${query[prop].join(' OR ')})`) } return acc