Skip to content

Commit

Permalink
Implement fulltext filter on search result page
Browse files Browse the repository at this point in the history
  • Loading branch information
JammingBen committed May 17, 2023
1 parent 30b9aab commit 65b03c0
Show file tree
Hide file tree
Showing 9 changed files with 234 additions and 17 deletions.
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 fulltext filter

The search result page now has a fulltext 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') : () => ({})"
>
<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-fulltext 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 fulltext = queryItemAsString(unref(fulltextParam))
if (fulltext) {
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-fulltext'
}

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

0 comments on commit 65b03c0

Please sign in to comment.