Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement fulltext filter on search result page #9059

Merged
merged 3 commits into from
May 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog/unreleased/enhancement-search-fulltext-filter
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Enhancement: Search full-text filter

The search result page now has a full-text filter which can be used to filter the displayed files by their content.

https://github.com/owncloud/web/pull/9059
https://github.com/owncloud/web/issues/9058
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { defaultPlugins, mount } from 'web-test-helpers'
import OcFilterChip from './OcFilterChip.vue'

const selectors = {
filterChipBtn: '.oc-filter-chip-button',
filterChipLabel: '.oc-filter-chip-label',
filterChipDrop: '.oc-filter-chip-drop',
filterChipBtnActive: '.oc-filter-chip-button-selected',
clearBtn: '.oc-filter-chip-clear'
}

Expand All @@ -22,6 +25,21 @@ describe('OcFilterChip', () => {
await wrapper.find(selectors.clearBtn).trigger('click')
expect(wrapper.emitted('clearFilter')).toBeTruthy()
})
describe('isToggle is true', () => {
it('does not render a dropdown', () => {
const { wrapper } = getWrapper({ props: { isToggle: true } })
expect(wrapper.find(selectors.filterChipDrop).exists()).toBeFalsy()
})
it('marks filter as active when isToggleActive is true', () => {
const { wrapper } = getWrapper({ props: { isToggle: true, isToggleActive: true } })
expect(wrapper.find(selectors.filterChipBtnActive).exists()).toBeTruthy()
})
it('emits the "toggleFilter"-event when clicking the button', async () => {
const { wrapper } = getWrapper({ props: { isToggle: true } })
await wrapper.find(selectors.filterChipBtn).trigger('click')
expect(wrapper.emitted('toggleFilter')).toBeTruthy()
})
})
})

const getWrapper = ({ props = {} } = {}) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
<template>
<div class="oc-filter-chip oc-flex">
<div class="oc-filter-chip oc-flex" :class="{ 'oc-filter-chip-toggle': isToggle }">
<oc-button
:id="id"
class="oc-filter-chip-button oc-pill"
:class="{ 'oc-filter-chip-button-selected': filterActive }"
appearance="raw"
@click="isToggle ? $emit('toggleFilter') : false"
>
<oc-icon v-if="filterActive" name="check" size="small" color="var(--oc-color-text-inverse)" />
<span
class="oc-text-truncate oc-filter-chip-label"
v-text="!!selectedItemNames.length ? selectedItemNames[0] : filterLabel"
/>
<span v-if="selectedItemNames.length > 1" v-text="` +${selectedItemNames.length - 1}`" />
<oc-icon v-if="!filterActive" name="arrow-down-s" size="small" />
<oc-icon v-if="!filterActive && !isToggle" name="arrow-down-s" size="small" />
</oc-button>
<oc-drop
v-if="!isToggle"
:toggle="'#' + id"
class="oc-filter-chip-drop"
mode="click"
Expand Down Expand Up @@ -50,19 +52,42 @@ export default defineComponent({
required: false,
default: () => uniqueId('oc-filter-chip-')
},
/**
* Label which is displayed when no items are selected.
*/
filterLabel: {
type: String,
required: true
},
/**
* An array of selected item names. It is being ignored when `isToggle` is set to `true`.
*/
selectedItemNames: {
type: Array,
required: false,
default: () => []
},
/**
* Display the filter chip as a on/off toggle.
*/
isToggle: {
type: Boolean,
default: false
},
/**
* Whether the toggle filter is active. It only has an effect if `isToggle` is set to `true`.
*/
isToggleActive: {
type: Boolean,
default: false
}
},
emits: ['clearFilter', 'hideDrop', 'showDrop'],
emits: ['clearFilter', 'hideDrop', 'showDrop', 'toggleFilter'],
setup(props) {
const filterActive = computed(() => {
if (props.isToggle) {
return props.isToggleActive
}
return !!props.selectedItemNames.length
})
return { filterActive }
Expand All @@ -83,7 +108,8 @@ export default defineComponent({
text-decoration: none;
font-size: var(--oc-font-size-xsmall);
line-height: 1rem;
max-width: 120px;
max-width: 150px;
height: 24px;
padding: var(--oc-space-xsmall) var(--oc-space-small) !important;

&-selected,
Expand All @@ -94,18 +120,21 @@ export default defineComponent({
border-bottom-left-radius: 99px !important;
border-top-right-radius: 0px !important;
border-bottom-right-radius: 0px !important;
border: 0;
}
}
&-clear,
&-clear:hover {
margin-left: 1px;
background-color: var(--oc-color-swatch-primary-default) !important;
color: var(--oc-color-text-inverse) !important;
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
border-top-right-radius: 99px !important;
border-bottom-right-radius: 99px !important;
}
&-clear:not(.oc-filter-chip-toggle .oc-filter-chip-clear),
&-clear:hover:not(.oc-filter-chip-toggle .oc-filter-chip-clear) {
margin-left: 1px;
}
}
</style>

23 changes: 20 additions & 3 deletions packages/web-app-files/src/components/Search/List.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@
<oc-avatar :width="24" :user-name="item.label" />
</template>
<template #item="{ item }">
<div v-text="item.label" />
<span v-text="item.label" />
</template>
</item-filter>
<item-filter-toggle
:filter-label="$gettext('Search in file content')"
filter-name="fullText"
class="files-search-filter-full-text oc-mr-s"
/>
</div>
<app-loading-spinner v-if="loading" />
<template v-else>
Expand Down Expand Up @@ -129,6 +134,7 @@ import { useTask } from 'vue-concurrency'
import { eventBus } from 'web-pkg'
import ItemFilter from 'web-pkg/src/components/ItemFilter.vue'
import { isLocationCommonActive } from 'web-app-files/src/router'
import ItemFilterToggle from 'web-pkg/src/components/ItemFilterToggle.vue'

const visibilityObserver = new VisibilityObserver()

Expand All @@ -148,7 +154,8 @@ export default defineComponent({
NoContentMessage,
ResourceTable,
FilesViewWrapper,
ItemFilter
ItemFilter,
ItemFilterToggle
},
props: {
searchResult: {
Expand Down Expand Up @@ -179,6 +186,8 @@ export default defineComponent({
const availableTags = ref<Tag[]>([])
const tagFilter = ref<VNodeRef>()
const tagParam = useRouteQuery('q_tags')
const fullTextParam = useRouteQuery('q_fullText')

const loadAvailableTagsTask = useTask(function* () {
const {
data: { value: tags = [] }
Expand All @@ -203,6 +212,11 @@ export default defineComponent({
const buildSearchTerm = (manuallyUpdateFilterChip = false) => {
let term = unref(searchTerm)

const fullTextQuery = queryItemAsString(unref(fullTextParam))
if (fullTextQuery) {
term = `Content:"${term}"`
}

const tags = queryItemAsString(unref(tagParam))
if (tags) {
const tagsTerm = tags.split('+')?.join(',') || ''
Expand Down Expand Up @@ -231,7 +245,10 @@ export default defineComponent({
watch(
() => unref(route).query,
(newVal, oldVal) => {
const isChange = newVal?.term !== oldVal?.term || newVal?.q_tags !== oldVal?.q_tags
const filters = ['q_fullText', 'q_tags']
const isChange =
newVal?.term !== oldVal?.term || filters.some((f) => newVal[f] ?? '' !== oldVal[f] ?? '')

if (isChange && isLocationCommonActive(router, 'files-common-search')) {
emit('search', buildSearchTerm(true))
}
Expand Down
29 changes: 26 additions & 3 deletions packages/web-app-files/tests/unit/components/Search/List.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ jest.mock('web-pkg/src/composables/appDefaults')
const selectors = {
noContentMessageStub: 'no-content-message-stub',
resourceTableStub: 'resource-table-stub',
tagFilter: '.files-search-filter-tags'
tagFilter: '.files-search-filter-tags',
fullTextFilter: '.files-search-filter-full-text'
}

describe('List component', () => {
Expand Down Expand Up @@ -50,19 +51,41 @@ describe('List component', () => {
])
})
it('should set initial filter when tags are given via query param', async () => {
const searchTerm = 'term'
const tagFilterQuery = 'tag1'
const { wrapper } = getWrapper({
availableTags: ['tag1'],
searchTerm,
tagFilterQuery
})
await wrapper.vm.loadAvailableTagsTask.last
expect(wrapper.emitted('search')[0][0]).toEqual(tagFilterQuery)
expect(wrapper.emitted('search')[0][0]).toEqual(`${searchTerm} Tags:"${tagFilterQuery}"`)
})
})
describe('fullText', () => {
it('should render filter', () => {
const { wrapper } = getWrapper()
expect(wrapper.find(selectors.fullTextFilter).exists()).toBeTruthy()
})
it('should set initial filter when fullText is set active via query param', async () => {
const searchTerm = 'term'
const { wrapper } = getWrapper({ searchTerm, fullTextFilterQuery: 'true' })
await wrapper.vm.loadAvailableTagsTask.last
expect(wrapper.emitted('search')[0][0]).toEqual(`Content:"${searchTerm}"`)
})
})
})
})

function getWrapper({ availableTags = [], resources = [], tagFilterQuery = null } = {}) {
function getWrapper({
availableTags = [],
resources = [],
searchTerm = '',
tagFilterQuery = null,
fullTextFilterQuery = null
} = {}) {
jest.mocked(queryItemAsString).mockImplementationOnce(() => searchTerm)
jest.mocked(queryItemAsString).mockImplementationOnce(() => fullTextFilterQuery)
jest.mocked(queryItemAsString).mockImplementationOnce(() => tagFilterQuery)

const resourcesViewDetailsMock = useResourcesViewDefaultsMock({
Expand Down
2 changes: 1 addition & 1 deletion packages/web-pkg/src/components/ItemFilter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<div>
<slot name="image" :item="item" />
</div>
<div>
<div class="oc-text-truncate">
<slot name="item" :item="item" />
</div>
</component>
Expand Down
68 changes: 68 additions & 0 deletions packages/web-pkg/src/components/ItemFilterToggle.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<template>
<div class="item-filter oc-flex" :class="`item-filter-${filterName}`">
<oc-filter-chip
:is-toggle="true"
:filter-label="filterLabel"
:is-toggle-active="filterActive"
@toggle-filter="toggleFilter"
@clear-filter="toggleFilter"
/>
</div>
</template>

<script lang="ts">
import { defineComponent, onMounted, ref, unref } from 'vue'
import omit from 'lodash-es/omit'
import { useRoute, useRouteQuery, useRouter } from 'web-pkg'
import { queryItemAsString } from 'web-pkg/src/composables/appDefaults'

export default defineComponent({
name: 'ItemFilterToggle',
props: {
filterLabel: {
type: String,
required: true
},
filterName: {
type: String,
required: true
}
},
emits: ['toggleFilter'],
setup: function (props, { emit }) {
const router = useRouter()
const currentRoute = useRoute()
const filterActive = ref<boolean>(false)

const queryParam = `q_${props.filterName}`
const currentRouteQuery = useRouteQuery(queryParam)
const setRouteQuery = () => {
return router.push({
query: {
...omit(unref(currentRoute).query, [queryParam]),
...(unref(filterActive) && { [queryParam]: 'true' })
}
})
}

const toggleFilter = async () => {
filterActive.value = !unref(filterActive)
await setRouteQuery()
emit('toggleFilter', unref(filterActive))
}

onMounted(() => {
const queryStr = queryItemAsString(unref(currentRouteQuery))
if (queryStr === 'true') {
filterActive.value = true
}
})

return {
queryParam,
filterActive,
toggleFilter
}
}
})
</script>
56 changes: 56 additions & 0 deletions packages/web-pkg/tests/unit/components/ItemFilterToggle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import ItemFilterToggle from 'web-pkg/src/components/ItemFilterToggle.vue'
import { defaultComponentMocks, defaultPlugins, mount } from 'web-test-helpers'
import { queryItemAsString } from 'web-pkg/src/composables/appDefaults'

jest.mock('web-pkg/src/composables/appDefaults')

const selectors = {
labelSpan: '.oc-filter-chip-label',
filterBtn: '.oc-filter-chip-button'
}

describe('ItemFilterToggle', () => {
it('renders the toggle filter including its label', () => {
const filterLabel = 'Toggle'
const { wrapper } = getWrapper({ props: { filterLabel } })
expect(wrapper.find(selectors.labelSpan).text()).toEqual(filterLabel)
})
it('emits the "toggleFilter"-event on click', async () => {
const { wrapper } = getWrapper()
await wrapper.find(selectors.filterBtn).trigger('click')
expect(wrapper.emitted('toggleFilter').length).toBeGreaterThan(0)
})
describe('route query', () => {
it('sets the active state as query param', async () => {
const { wrapper, mocks } = getWrapper()
const currentRouteQuery = (mocks.$router.currentRoute as any).query
expect(mocks.$router.push).not.toHaveBeenCalled()
await wrapper.find(selectors.filterBtn).trigger('click')
expect(currentRouteQuery[wrapper.vm.queryParam]).toBeDefined()
expect(mocks.$router.push).toHaveBeenCalled()
})
it('sets the active state initially when given via query param', () => {
const { wrapper } = getWrapper({ initialQuery: 'true' })
expect(wrapper.vm.filterActive).toEqual(true)
})
})
})

function getWrapper({ props = {}, initialQuery = '' } = {}) {
jest.mocked(queryItemAsString).mockImplementation(() => initialQuery)
const mocks = defaultComponentMocks()
return {
mocks,
wrapper: mount(ItemFilterToggle, {
props: {
filterLabel: 'Toggle',
filterName: 'toggle',
...props
},
global: {
plugins: [...defaultPlugins()],
mocks
}
})
}
}
Loading