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

Add "File type" filter Chip #9912

Merged
merged 21 commits into from
Nov 14, 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
2 changes: 1 addition & 1 deletion .drone.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# The version of OCIS to use in pipelines that test against OCIS
OCIS_COMMITID=a9366caa0cb97de0c4a68a43f05156cc584bee16
OCIS_COMMITID=ef1b18aeacea86be2a6918827c84f0ed99043b1e
OCIS_BRANCH=master
6 changes: 6 additions & 0 deletions changelog/unreleased/enhancement-add-file-media-filter-chip
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Enhancement: Add media type filter chip

We've added a new filter option in the search list to filter by media type.

https://github.com/owncloud/web/pull/9912
https://github.com/owncloud/web/issues/9780
92 changes: 85 additions & 7 deletions packages/web-app-files/src/components/Search/List.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@
<oc-icon name="filter-2" class="oc-mr-xs" />
<span v-text="$gettext('Filter:')" />
</div>
<item-filter
v-if="availableMediaTypeValues.length"
ref="mediaTypeFilter"
:allow-multiple="true"
:filter-label="$gettext('Media type')"
:filterable-attributes="['label']"
:items="availableMediaTypeValues"
:option-filter-label="$gettext('Filter media type')"
:show-option-filter="true"
class="files-search-filter-file-type oc-mr-s"
display-name-attribute="label"
filter-name="mediaType"
>
<template #image="{ item }">
<div
class="file-category-option-wrapper oc-flex oc-flex-middle"
:data-test-id="`media-type-${item.id.toLowerCase()}`"
>
<oc-resource-icon :resource="getFakeResourceForIcon(item)" />
<span class="oc-ml-s">{{ item.label }}</span>
</div>
</template>
</item-filter>
<item-filter
v-if="availableTags.length"
ref="tagFilter"
Expand Down Expand Up @@ -126,7 +149,11 @@

<script lang="ts">
import { useResourcesViewDefaults } from '../../composables'
import { AppLoadingSpinner, useCapabilitySearchModifiedDate } from '@ownclouders/web-pkg'
import {
AppLoadingSpinner,
useCapabilitySearchMediaType,
useCapabilitySearchModifiedDate
} from '@ownclouders/web-pkg'
import { VisibilityObserver } from '@ownclouders/web-pkg'
import { ImageType, ImageDimension } from '@ownclouders/web-pkg'
import { NoContentMessage } from '@ownclouders/web-pkg'
Expand Down Expand Up @@ -181,6 +208,11 @@ import {

const visibilityObserver = new VisibilityObserver()

type FileCategoryKeyword = {
id: string
label: string
icon: string
}
type Tag = {
id: string
label: string
Expand Down Expand Up @@ -227,14 +259,14 @@ export default defineComponent({
const hasTags = useCapabilityFilesTags()
const fullTextSearchEnabled = useCapabilityFilesFullTextSearch()
const modifiedDateCapability = useCapabilitySearchModifiedDate()
const mediaTypeCapability = useCapabilitySearchMediaType()
const { getMatchingSpace } = useGetMatchingSpace()

const searchTermQuery = useRouteQuery('term')
const scopeQuery = useRouteQuery('scope')
const doUseScope = useRouteQuery('useScope')

const resourcesView = useResourcesViewDefaults<Resource, any, any[]>()

const keyActions = useKeyboardActions()
useKeyboardTableNavigation(keyActions, resourcesView.paginatedResources, resourcesView.viewMode)
useKeyboardTableMouseActions(keyActions, resourcesView.viewMode)
Expand All @@ -246,12 +278,18 @@ export default defineComponent({

const availableTags = ref<Tag[]>([])
const tagFilter = ref<VNodeRef>()
const mediaTypeFilter = ref<VNodeRef>()
const tagParam = useRouteQuery('q_tags')
const lastModifiedParam = useRouteQuery('q_lastModified')
const mediaTypeParam = useRouteQuery('q_mediaType')
const fullTextParam = useRouteQuery('q_fullText')

const displayFilter = computed(() => {
return unref(fullTextSearchEnabled) || unref(availableTags).length
return (
unref(fullTextSearchEnabled) ||
unref(availableTags).length ||
(unref(modifiedDateCapability) && unref(modifiedDateCapability).enabled)
)
})

const loadAvailableTagsTask = useTask(function* () {
Expand Down Expand Up @@ -287,6 +325,33 @@ export default defineComponent({
})) || []
)

const mediaTypeMapping = {
file: { label: $gettext('File'), icon: 'txt' },
folder: { label: $gettext('Folder'), icon: 'folder' },
document: { label: $gettext('Document'), icon: 'doc' },
spreadsheet: { label: $gettext('Spreadsheet'), icon: 'xls' },
presentation: { label: $gettext('Presentation'), icon: 'ppt' },
pdf: { label: $gettext('PDF'), icon: 'pdf' },
image: { label: $gettext('Image'), icon: 'jpg' },
video: { label: $gettext('Video'), icon: 'mp4' },
audio: { label: $gettext('Audio'), icon: 'mp3' },
archive: { label: $gettext('Archive'), icon: 'zip' }
}
const availableMediaTypeValues: FileCategoryKeyword[] = []
unref(mediaTypeCapability).keywords?.forEach((key: string) => {
if (!mediaTypeMapping[key]) {
return
}
availableMediaTypeValues.push({
id: key,
...mediaTypeMapping[key]
})
})
lookacat marked this conversation as resolved.
Show resolved Hide resolved

const getFakeResourceForIcon = (item) => {
return { type: 'file', extension: item.icon, isFolder: item.icon == 'folder' } as Resource
}

const buildSearchTerm = (manuallyUpdateFilterChip = false) => {
const query = {}

Expand Down Expand Up @@ -329,6 +394,12 @@ export default defineComponent({
updateFilter(lastModifiedFilter)
}

const mediaTypeParams = queryItemAsString(unref(mediaTypeParam))
if (mediaTypeParams) {
query['mediatype'] = mediaTypeParams.split('+').map((t) => `"${t}"`)
updateFilter(mediaTypeFilter)
}

return (
// By definition (KQL spec) OR, AND or (GROUP) is implicit for simple cases where
// different or identical keys are part of the query.
Expand Down Expand Up @@ -391,9 +462,13 @@ export default defineComponent({
// return early if the search term or filter has not changed, no search needed
{
const isSameTerm = newVal?.term === oldVal?.term
const isSameFilter = ['q_fullText', 'q_tags', 'q_lastModified', 'useScope'].every(
(key) => newVal[key] === oldVal[key]
)
const isSameFilter = [
'q_fullText',
'q_tags',
'q_lastModified',
'q_mediaType',
'useScope'
].every((key) => newVal[key] === oldVal[key])
if (isSameTerm && isSameFilter) {
return
}
Expand All @@ -416,7 +491,10 @@ export default defineComponent({
breadcrumbs,
displayFilter,
availableLastModifiedValues,
lastModifiedFilter
lastModifiedFilter,
mediaTypeFilter,
availableMediaTypeValues,
getFakeResourceForIcon
}
},
computed: {
Expand Down
7 changes: 7 additions & 0 deletions packages/web-client/src/ocs/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,19 @@ export interface LastModifiedFilterCapability {
keywords?: string[]
enabled?: boolean
}

export interface MediaTypeCapability {
keywords?: string[]
enabled?: boolean
}

export interface Capabilities {
capabilities: {
password_policy?: PasswordPolicyCapability
search: {
property: {
mtime: LastModifiedFilterCapability
mimetype: MediaTypeCapability
}
}
notifications: {
Expand Down
6 changes: 6 additions & 0 deletions packages/web-pkg/src/composables/capability/useCapability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useStore } from '../store'
import {
AppProviderCapability,
LastModifiedFilterCapability,
MediaTypeCapability,
PasswordPolicyCapability
} from '@ownclouders/web-client/src/ocs/capabilities'

Expand Down Expand Up @@ -146,3 +147,8 @@ export const useCapabilityPasswordPolicy = createCapabilityComposable<PasswordPo

export const useCapabilitySearchModifiedDate =
createCapabilityComposable<LastModifiedFilterCapability>('search.property.mtime', {})

export const useCapabilitySearchMediaType = createCapabilityComposable<MediaTypeCapability>(
'search.property.mediatype',
{}
)
1 change: 1 addition & 0 deletions packages/web-pkg/src/router/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const buildRoutes = (components: RouteComponents): RouteRecordRaw[] => [
'q_tags',
'q_lastModified',
'q_fullText',
'q_mediaType',
'scope',
'useScope'
]
Expand Down
34 changes: 34 additions & 0 deletions tests/e2e/cucumber/features/smoke/search.feature
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,37 @@ Feature: Search
| resource |
| exampleInsideThePersonalSpace.txt |
And "Alice" logs out

Scenario: Search using mediaType filter
Given "Admin" creates following users using API
| id |
| Alice |
And "Alice" logs in
And "Alice" creates the following files into personal space using API
| pathToFile | content |
| mediaTest.txt | I'm a Document |
| mediaTest.pdf | I'm a PDF |
| mediaTest.mp3 | I'm a Audio |
| mediaTest.zip | I'm a Archive |
And "Alice" opens the "files" app
And "Alice" searches "mediaTest" using the global search and the "everywhere" filter and presses enter
And "Alice" selects mediaType "Document" from the search result filter chip
Then following resources should be displayed in the files list for user "Alice"
| resource |
| mediaTest.txt |
And "Alice" clears mediaType filter
And "Alice" selects mediaType "PDF" from the search result filter chip
Then following resources should be displayed in the files list for user "Alice"
| resource |
| mediaTest.pdf |
And "Alice" clears mediaType filter
And "Alice" selects mediaType "Audio" from the search result filter chip
Then following resources should be displayed in the files list for user "Alice"
| resource |
| mediaTest.mp3 |
And "Alice" clears mediaType filter
And "Alice" selects mediaType "Archive" from the search result filter chip
Then following resources should be displayed in the files list for user "Alice"
| resource |
| mediaTest.zip |
AlexAndBear marked this conversation as resolved.
Show resolved Hide resolved
And "Alice" logs out
16 changes: 16 additions & 0 deletions tests/e2e/cucumber/steps/ui/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,19 @@ When(
await searchObject.toggleSearchInFileContent({ enableOrDisable })
}
)
When(
'{string} selects mediaType {string} from the search result filter chip',
async function (this: World, stepUser: string, mediaType: string): Promise<void> {
const { page } = this.actorsEnvironment.getActor({ key: stepUser })
const searchObject = new objects.applicationFiles.Search({ page })
await searchObject.selectMediaTypeFilter({ mediaType })
}
)
When(
'{string} clears mediaType filter',
async function (this: World, stepUser: string): Promise<void> {
const { page } = this.actorsEnvironment.getActor({ key: stepUser })
const searchObject = new objects.applicationFiles.Search({ page })
await searchObject.clearMediaTypeFilter()
}
)
21 changes: 21 additions & 0 deletions tests/e2e/support/objects/app-files/search/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ const searchResultMessageSelector = '//p[@class="oc-text-muted"]'
const selectTagDropdownSelector =
'//div[contains(@class,"files-search-filter-tags")]//button[contains(@class,"oc-filter-chip-button")]'
const tagFilterChipSelector = '//button[contains(@data-test-value,"%s")]'
const mediaTypeFilterSelector = '.item-filter-mediaType'
const mediaTypeFilterItem = '[data-test-id="media-type-%s"]'
const mediaTypeOutside = '.files-search-result-filter'
const clearTagFilterSelector =
'//div[contains(@class,"files-search-filter-tags")]//button[contains(@class,"oc-filter-chip-clear")]'
const clearMediaTypeFilterSelector =
'//div[contains(@class,"item-filter-mediaType")]//button[contains(@class,"oc-filter-chip-clear")]'
const enableSearchInFileContentSelector =
'//div[contains(@class,"files-search-filter-full-text")]//button[contains(@class,"oc-filter-chip-button")]'
const disableSearchInFileContentSelector =
Expand All @@ -31,6 +36,22 @@ export const clearTagFilter = async ({ page }: { page: Page }): Promise<void> =>
await page.locator(clearTagFilterSelector).click()
}

export const selectMediaTypeFilter = async ({
mediaType,
page
}: {
mediaType: string
page: Page
}): Promise<void> => {
await page.locator(mediaTypeFilterSelector).click()
await page.locator(util.format(mediaTypeFilterItem, mediaType.toLowerCase())).click()
await page.locator(mediaTypeOutside).click()
}

export const clearMediaTypeFilter = async ({ page }: { page: Page }): Promise<void> => {
await page.locator(clearMediaTypeFilterSelector).click()
}

export const toggleSearchInFileContent = async ({
enableOrDisable,
page
Expand Down
8 changes: 8 additions & 0 deletions tests/e2e/support/objects/app-files/search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,18 @@ export class Search {
await po.selectTagFilter({ tag: string, page: this.#page })
}

async selectMediaTypeFilter({ mediaType: string }): Promise<void> {
await po.selectMediaTypeFilter({ mediaType: string, page: this.#page })
}

async clearTagFilter(): Promise<void> {
await po.clearTagFilter({ page: this.#page })
}

async clearMediaTypeFilter(): Promise<void> {
await po.clearMediaTypeFilter({ page: this.#page })
}

async toggleSearchInFileContent({ enableOrDisable: string }): Promise<void> {
await po.toggleSearchInFileContent({ enableOrDisable: string, page: this.#page })
}
Expand Down