diff --git a/.drone.env b/.drone.env index c8fe09bb93b..103265ab8e4 100644 --- a/.drone.env +++ b/.drone.env @@ -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 diff --git a/changelog/unreleased/enhancement-add-file-media-filter-chip b/changelog/unreleased/enhancement-add-file-media-filter-chip new file mode 100644 index 00000000000..dbc36a64187 --- /dev/null +++ b/changelog/unreleased/enhancement-add-file-media-filter-chip @@ -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 diff --git a/packages/web-app-files/src/components/Search/List.vue b/packages/web-app-files/src/components/Search/List.vue index 026469247f5..73cdb38e4c5 100644 --- a/packages/web-app-files/src/components/Search/List.vue +++ b/packages/web-app-files/src/components/Search/List.vue @@ -7,6 +7,29 @@ + + + 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' @@ -181,6 +208,11 @@ import { const visibilityObserver = new VisibilityObserver() +type FileCategoryKeyword = { + id: string + label: string + icon: string +} type Tag = { id: string label: string @@ -227,6 +259,7 @@ export default defineComponent({ const hasTags = useCapabilityFilesTags() const fullTextSearchEnabled = useCapabilityFilesFullTextSearch() const modifiedDateCapability = useCapabilitySearchModifiedDate() + const mediaTypeCapability = useCapabilitySearchMediaType() const { getMatchingSpace } = useGetMatchingSpace() const searchTermQuery = useRouteQuery('term') @@ -234,7 +267,6 @@ export default defineComponent({ const doUseScope = useRouteQuery('useScope') const resourcesView = useResourcesViewDefaults() - const keyActions = useKeyboardActions() useKeyboardTableNavigation(keyActions, resourcesView.paginatedResources, resourcesView.viewMode) useKeyboardTableMouseActions(keyActions, resourcesView.viewMode) @@ -246,12 +278,18 @@ export default defineComponent({ const availableTags = ref([]) const tagFilter = ref() + const mediaTypeFilter = ref() 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* () { @@ -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] + }) + }) + + const getFakeResourceForIcon = (item) => { + return { type: 'file', extension: item.icon, isFolder: item.icon == 'folder' } as Resource + } + const buildSearchTerm = (manuallyUpdateFilterChip = false) => { const query = {} @@ -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. @@ -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 } @@ -416,7 +491,10 @@ export default defineComponent({ breadcrumbs, displayFilter, availableLastModifiedValues, - lastModifiedFilter + lastModifiedFilter, + mediaTypeFilter, + availableMediaTypeValues, + getFakeResourceForIcon } }, computed: { diff --git a/packages/web-client/src/ocs/capabilities.ts b/packages/web-client/src/ocs/capabilities.ts index 0c53ebaeb82..73e9abc541f 100644 --- a/packages/web-client/src/ocs/capabilities.ts +++ b/packages/web-client/src/ocs/capabilities.ts @@ -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: { diff --git a/packages/web-pkg/src/composables/capability/useCapability.ts b/packages/web-pkg/src/composables/capability/useCapability.ts index 15abde5c3db..332e3ab4df1 100644 --- a/packages/web-pkg/src/composables/capability/useCapability.ts +++ b/packages/web-pkg/src/composables/capability/useCapability.ts @@ -5,6 +5,7 @@ import { useStore } from '../store' import { AppProviderCapability, LastModifiedFilterCapability, + MediaTypeCapability, PasswordPolicyCapability } from '@ownclouders/web-client/src/ocs/capabilities' @@ -146,3 +147,8 @@ export const useCapabilityPasswordPolicy = createCapabilityComposable('search.property.mtime', {}) + +export const useCapabilitySearchMediaType = createCapabilityComposable( + 'search.property.mediatype', + {} +) diff --git a/packages/web-pkg/src/router/common.ts b/packages/web-pkg/src/router/common.ts index 00d11c257b2..d37dfe5033e 100644 --- a/packages/web-pkg/src/router/common.ts +++ b/packages/web-pkg/src/router/common.ts @@ -33,6 +33,7 @@ export const buildRoutes = (components: RouteComponents): RouteRecordRaw[] => [ 'q_tags', 'q_lastModified', 'q_fullText', + 'q_mediaType', 'scope', 'useScope' ] diff --git a/tests/e2e/cucumber/features/smoke/search.feature b/tests/e2e/cucumber/features/smoke/search.feature index 929fffe36f6..4439079a7f1 100644 --- a/tests/e2e/cucumber/features/smoke/search.feature +++ b/tests/e2e/cucumber/features/smoke/search.feature @@ -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 | + And "Alice" logs out diff --git a/tests/e2e/cucumber/steps/ui/search.ts b/tests/e2e/cucumber/steps/ui/search.ts index 8c0df6d0aec..562d0d4f570 100644 --- a/tests/e2e/cucumber/steps/ui/search.ts +++ b/tests/e2e/cucumber/steps/ui/search.ts @@ -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 { + 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 { + const { page } = this.actorsEnvironment.getActor({ key: stepUser }) + const searchObject = new objects.applicationFiles.Search({ page }) + await searchObject.clearMediaTypeFilter() + } +) diff --git a/tests/e2e/support/objects/app-files/search/actions.ts b/tests/e2e/support/objects/app-files/search/actions.ts index 8316eaa0a19..3eb2eb2c4fa 100644 --- a/tests/e2e/support/objects/app-files/search/actions.ts +++ b/tests/e2e/support/objects/app-files/search/actions.ts @@ -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 = @@ -31,6 +36,22 @@ export const clearTagFilter = async ({ page }: { page: Page }): Promise => await page.locator(clearTagFilterSelector).click() } +export const selectMediaTypeFilter = async ({ + mediaType, + page +}: { + mediaType: string + page: Page +}): Promise => { + 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 => { + await page.locator(clearMediaTypeFilterSelector).click() +} + export const toggleSearchInFileContent = async ({ enableOrDisable, page diff --git a/tests/e2e/support/objects/app-files/search/index.ts b/tests/e2e/support/objects/app-files/search/index.ts index 879f329c330..5a7844ce899 100644 --- a/tests/e2e/support/objects/app-files/search/index.ts +++ b/tests/e2e/support/objects/app-files/search/index.ts @@ -16,10 +16,18 @@ export class Search { await po.selectTagFilter({ tag: string, page: this.#page }) } + async selectMediaTypeFilter({ mediaType: string }): Promise { + await po.selectMediaTypeFilter({ mediaType: string, page: this.#page }) + } + async clearTagFilter(): Promise { await po.clearTagFilter({ page: this.#page }) } + async clearMediaTypeFilter(): Promise { + await po.clearMediaTypeFilter({ page: this.#page }) + } + async toggleSearchInFileContent({ enableOrDisable: string }): Promise { await po.toggleSearchInFileContent({ enableOrDisable: string, page: this.#page }) }