diff --git a/changelog/unreleased/enhancement-manage-tags-in-details-panel b/changelog/unreleased/enhancement-manage-tags-in-details-panel new file mode 100644 index 00000000000..8ed21599114 --- /dev/null +++ b/changelog/unreleased/enhancement-manage-tags-in-details-panel @@ -0,0 +1,8 @@ +Enhancement: Manage tags in details panel + +We've enhanced the details panel, now tags are viewable and manageable there. +That change makes it easier for the user to manage tags, as they don't need to click an additional +context menu action. + +https://github.com/owncloud/web/pull/9905 +https://github.com/owncloud/web/issues/9783 diff --git a/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue b/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue index 6de016d12a9..0f4b4787ca5 100644 --- a/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue +++ b/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue @@ -98,18 +98,17 @@ :slot-props="{ space, resource }" :multiple="true" /> - - + + + {{ $gettext('Tags') }} + + - - - {{ tag }} - , - + @@ -120,9 +119,8 @@ - diff --git a/packages/web-app-files/src/fileSideBars.ts b/packages/web-app-files/src/fileSideBars.ts index 7995f86afc8..68af03cf3cd 100644 --- a/packages/web-app-files/src/fileSideBars.ts +++ b/packages/web-app-files/src/fileSideBars.ts @@ -3,7 +3,6 @@ import FileDetailsMultiple from './components/SideBar/Details/FileDetailsMultipl import FileActions from './components/SideBar/Actions/FileActions.vue' import FileVersions from './components/SideBar/Versions/FileVersions.vue' import SharesPanel from './components/SideBar/Shares/SharesPanel.vue' -import TagsPanel from './components/SideBar/TagsPanel.vue' import NoSelection from './components/SideBar/NoSelection.vue' import SpaceActions from './components/SideBar/Actions/SpaceActions.vue' import { SpaceDetails } from '@ownclouders/web-pkg' @@ -195,32 +194,6 @@ const panelGenerators: (({ return false } }), - ({ capabilities, resource, router, multipleSelection, rootFolder }) => ({ - app: 'tags', - icon: 'price-tag-3', - iconFillType: 'line', - title: $gettext('Tags'), - component: TagsPanel, - componentAttrs: {}, - get enabled() { - if ( - !capabilities?.files?.tags || - multipleSelection || - rootFolder || - !resource || - resource.type === 'space' - ) { - return false - } - if (typeof resource.canEditTags !== 'function' || !resource.canEditTags()) { - return false - } - return !( - isLocationTrashActive(router, 'files-trash-generic') || - isLocationPublicActive(router, 'files-public-link') - ) - } - }), ({ multipleSelection, resource, capabilities }) => ({ app: 'space-share', icon: 'group', diff --git a/packages/web-app-files/tests/unit/components/SideBar/Details/FileDetails.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/Details/FileDetails.spec.ts index 7a93e1a101c..3a2b4c8adf3 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Details/FileDetails.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/Details/FileDetails.spec.ts @@ -4,11 +4,13 @@ import { createStore, defaultComponentMocks, defaultPlugins, - shallowMount, - defaultStoreMockOptions + defaultStoreMockOptions, + RouteLocation } from 'web-test-helpers' import { mock, mockDeep } from 'jest-mock-extended' import { Resource, SpaceResource } from '@ownclouders/web-client/src/helpers' +import { createLocationSpaces, createLocationPublic } from '@ownclouders/web-pkg/' +import { mount } from '@vue/test-utils' const getResourceMock = ({ type = 'file', @@ -18,7 +20,8 @@ const getResourceMock = ({ shareTypes = [], share = null, path = '/somePath/someResource', - locked = false + locked = false, + canEditTags = true } = {}) => mock({ id: '1', @@ -35,7 +38,8 @@ const getResourceMock = ({ thumbnail, shareTypes, share, - locked + locked, + canEditTags: jest.fn(() => canEditTags) }) const selectors = { @@ -150,20 +154,38 @@ describe('Details SideBar Panel', () => { expect(wrapper.find(selectors.versionsInfo).exists()).toBeFalsy() }) }) + describe('tags', () => { - it('show if given', () => { - const resource = getResourceMock({ tags: ['moon', 'mars'] }) + it('shows when enabled via capabilities', async () => { + const resource = getResourceMock() const { wrapper } = createWrapper({ resource }) expect(wrapper.find(selectors.tags).exists()).toBeTruthy() }) - it('should use router-link on private page', () => { + it('does not show when disabled via capabilities', () => { + const resource = getResourceMock() + const { wrapper } = createWrapper({ resource, tagsEnabled: false }) + expect(wrapper.find(selectors.tags).exists()).toBeFalsy() + }) + it('does not show for root folders', () => { + const resource = getResourceMock({ path: '/' }) + const { wrapper } = createWrapper({ resource }) + expect(wrapper.find(selectors.tags).exists()).toBeTruthy() + }) + it('shows as disabled when permission not set', () => { + const resource = getResourceMock({ canEditTags: false }) + const { wrapper } = createWrapper({ resource }) + expect(wrapper.find(selectors.tags).find('.vs--disabled ').exists()).toBeTruthy() + }) + it('should use router-link on private page', async () => { const resource = getResourceMock({ tags: ['moon', 'mars'] }) const { wrapper } = createWrapper({ resource }) + await wrapper.vm.$nextTick() expect(wrapper.find(selectors.tags).find('router-link-stub').exists()).toBeTruthy() }) - it('should not use router-link on public page', () => { + it('should not use router-link on public page', async () => { const resource = getResourceMock({ tags: ['moon', 'mars'] }) const { wrapper } = createWrapper({ resource, isPublicLinkContext: true }) + await wrapper.vm.$nextTick() expect(wrapper.find(selectors.tags).find('router-link-stub').exists()).toBeFalsy() }) }) @@ -174,12 +196,13 @@ function createWrapper({ isPublicLinkContext = false, ancestorMetaData = {}, user = { id: 'marie' }, - versions = [] + versions = [], + tagsEnabled = true } = {}) { const storeOptions = defaultStoreMockOptions storeOptions.getters.user.mockReturnValue(user) storeOptions.modules.Files.getters.versions.mockReturnValue(versions) - storeOptions.getters.capabilities.mockReturnValue({ files: { tags: true } }) + storeOptions.getters.capabilities.mockReturnValue({ files: { tags: tagsEnabled } }) storeOptions.modules.runtime.modules.ancestorMetaData.getters.ancestorMetaData.mockReturnValue( ancestorMetaData ) @@ -187,9 +210,13 @@ function createWrapper({ isPublicLinkContext ) const store = createStore(storeOptions) - const mocks = { ...defaultComponentMocks() } + + const spacesLocation = createLocationSpaces('files-spaces-generic') + const publicLocation = createLocationPublic('files-public-link') + const currentRoute = isPublicLinkContext ? publicLocation : spacesLocation + const mocks = defaultComponentMocks({ currentRoute: mock(currentRoute as any) }) return { - wrapper: shallowMount(FileDetails, { + wrapper: mount(FileDetails, { global: { stubs: { 'router-link': true, 'oc-resource-icon': true }, provide: { diff --git a/packages/web-app-files/tests/unit/components/SideBar/SideBar.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/SideBar.spec.ts index 51e091f4c41..922cc768c11 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/SideBar.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/SideBar.spec.ts @@ -1,9 +1,4 @@ import fileSideBars from 'web-app-files/src/fileSideBars' -import { - createLocationPublic, - createLocationSpaces, - createLocationTrash -} from '@ownclouders/web-pkg' import SideBar from 'web-app-files/src/components/SideBar/SideBar.vue' import { Resource } from '@ownclouders/web-client/src/helpers' import { mock, mockDeep } from 'jest-mock-extended' @@ -132,58 +127,9 @@ describe('SideBar', () => { }) }) }) - describe('tags panel', () => { - it('shows when enabled via capabilities and possible on the resource', async () => { - const item = mockDeep({ path: '/someFolder', canEditTags: () => true }) - const { wrapper } = createWrapper({ item }) - wrapper.vm.loadedResource = item - await wrapper.vm.$nextTick() - const panels = wrapper.findComponent({ ref: 'sidebar' }).props('availablePanels') - expect(panels.some(({ app }) => app === 'tags')).toBeTruthy() - }) - it('does not show when disabled via capabilities', async () => { - const item = mockDeep({ path: '/someFolder', canEditTags: () => true }) - const { wrapper } = createWrapper({ item, tagsEnabled: false }) - wrapper.vm.loadedResource = item - await wrapper.vm.$nextTick() - const panels = wrapper.findComponent({ ref: 'sidebar' }).props('availablePanels') - expect(panels.some(({ app }) => app === 'tags')).toBeFalsy() - }) - it('does not show for root folders', async () => { - const item = mockDeep({ path: '/', canEditTags: () => true }) - const { wrapper } = createWrapper({ item }) - wrapper.vm.loadedResource = item - await wrapper.vm.$nextTick() - const panels = wrapper.findComponent({ ref: 'sidebar' }).props('availablePanels') - expect(panels.some(({ app }) => app === 'tags')).toBeFalsy() - }) - it('does not show when not possible on the resource', async () => { - const item = mockDeep({ path: '/someFolder', canEditTags: () => false }) - const { wrapper } = createWrapper({ item }) - wrapper.vm.loadedResource = item - await wrapper.vm.$nextTick() - const panels = wrapper.findComponent({ ref: 'sidebar' }).props('availablePanels') - expect(panels.some(({ app }) => app === 'tags')).toBeFalsy() - }) - it.each([ - createLocationTrash('files-trash-generic'), - createLocationPublic('files-public-link') - ])('does not show on trash and public routes', async (currentRoute) => { - const item = mockDeep({ path: '/someFolder', canEditTags: () => true }) - const { wrapper } = createWrapper({ item, currentRoute }) - wrapper.vm.loadedResource = item - await wrapper.vm.$nextTick() - const panels = wrapper.findComponent({ ref: 'sidebar' }).props('availablePanels') - expect(panels.some(({ app }) => app === 'tags')).toBeFalsy() - }) - }) }) -function createWrapper({ - item = undefined, - currentRoute = createLocationSpaces('files-spaces-generic'), - tagsEnabled = true -} = {}) { +function createWrapper({ item = undefined } = {}) { const storeOptions = { ...defaultStoreMockOptions, getters: { @@ -192,7 +138,6 @@ function createWrapper({ return { id: 'marie' } }, capabilities: () => ({ - files: { tags: tagsEnabled }, files_sharing: { api_enabled: true, public: { enabled: true } @@ -206,7 +151,9 @@ function createWrapper({ (state) => state.highlightedFile ) const store = createStore(storeOptions) - const mocks = defaultComponentMocks({ currentRoute: mock(currentRoute as any) }) + const mocks = defaultComponentMocks({ + currentRoute: mock({ name: 'files-spaces-generic' }) + }) return { wrapper: shallowMount(SideBar, { props: { diff --git a/packages/web-app-files/tests/unit/components/SideBar/TagsPanel.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/TagsSelect.spec.ts similarity index 94% rename from packages/web-app-files/tests/unit/components/SideBar/TagsPanel.spec.ts rename to packages/web-app-files/tests/unit/components/SideBar/TagsSelect.spec.ts index 3dac04f000d..c8398263fd7 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/TagsPanel.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/TagsSelect.spec.ts @@ -6,7 +6,7 @@ import { defaultPlugins, mockAxiosResolve } from 'web-test-helpers' -import TagsPanel from 'web-app-files/src/components/SideBar/TagsPanel.vue' +import TagsSelect from 'web-app-files/src/components/SideBar/Details/TagsSelect.vue' import { mockDeep } from 'jest-mock-extended' import { Resource } from '@ownclouders/web-client' import { ClientService, eventBus } from '@ownclouders/web-pkg' @@ -16,11 +16,11 @@ jest.mock('@ownclouders/web-pkg', () => ({ useAccessToken: jest.fn() })) -describe('Tags Panel', () => { +describe('Tag Select', () => { it('show tags input form if loaded successfully', () => { const resource = mockDeep({ tags: [] }) const { wrapper } = createWrapper(resource) - expect(wrapper.find('#tags-form').exists()).toBeTruthy() + expect(wrapper.find('[data-test-id="tags-select"]').exists()).toBeTruthy() }) it('all available tags are selectable', async () => { @@ -143,12 +143,15 @@ function createWrapper(resource, clientService = mockDeep(), stub const mocks = { ...defaultComponentMocks(), $clientService: clientService } return { storeOptions, - wrapper: mount(TagsPanel, { + wrapper: mount(TagsSelect, { global: { plugins: [...defaultPlugins(), store], mocks, - provide: { ...mocks, resource }, + provide: { ...mocks }, stubs: { VueSelect: stubVueSelect, CompareSaveDialog: true } + }, + props: { + resource } }) } diff --git a/packages/web-pkg/src/components/FilesList/ContextActions.vue b/packages/web-pkg/src/components/FilesList/ContextActions.vue index 815f11d2f36..d0a068a915d 100644 --- a/packages/web-pkg/src/components/FilesList/ContextActions.vue +++ b/packages/web-pkg/src/components/FilesList/ContextActions.vue @@ -28,7 +28,6 @@ import { useFileActionsDownloadFile, useFileActionsRename, useFileActionsSetImage, - useFileActionsShowEditTags, useFileActionsNavigate, useFileActionsFavorite, useFileActionsCreateSpaceFromResource, @@ -70,7 +69,6 @@ export default defineComponent({ const { actions: setSpaceImageActions } = useFileActionsSetImage({ store }) const { actions: setSpaceReadmeActions } = useFileActionsSetReadme({ store }) const { actions: showDetailsActions } = useFileActionsShowDetails({ store }) - const { actions: showEditTagsActions } = useFileActionsShowEditTags({ store }) const { actions: createSpaceFromResourceActions } = useFileActionsCreateSpaceFromResource({ store }) @@ -121,7 +119,6 @@ export default defineComponent({ ...unref(pasteActions), ...unref(renameActions), ...unref(createSpaceFromResourceActions), - ...unref(showEditTagsActions), ...unref(restoreActions), ...unref(acceptShareActions), ...unref(declineShareActions), diff --git a/packages/web-pkg/src/composables/actions/files/index.ts b/packages/web-pkg/src/composables/actions/files/index.ts index 5cfdf0ad54d..01a7e27eda3 100644 --- a/packages/web-pkg/src/composables/actions/files/index.ts +++ b/packages/web-pkg/src/composables/actions/files/index.ts @@ -18,7 +18,6 @@ export * from './useFileActionsRestore' export * from './useFileActionsSetImage' export * from './useFileActionsShowActions' export * from './useFileActionsShowDetails' -export * from './useFileActionsShowEditTags' export * from './useFileActionsShowShares' export * from './useFileActionsCreateSpaceFromResource' export * from './useFileActionsCreateNewFolder' diff --git a/packages/web-pkg/src/composables/actions/files/useFileActions.ts b/packages/web-pkg/src/composables/actions/files/useFileActions.ts index d8f3ff895f2..fb71e2cebf0 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActions.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActions.ts @@ -32,7 +32,6 @@ import { useFileActionsNavigate, useFileActionsRename, useFileActionsRestore, - useFileActionsShowEditTags, useFileActionsCreateSpaceFromResource } from './index' @@ -67,7 +66,6 @@ export const useFileActions = ({ store }: { store?: Store } = {}) => { const { actions: navigateActions } = useFileActionsNavigate({ store }) const { actions: renameActions } = useFileActionsRename({ store }) const { actions: restoreActions } = useFileActionsRestore({ store }) - const { actions: showEditTagsActions } = useFileActionsShowEditTags({ store }) const { actions: createSpaceFromResource } = useFileActionsCreateSpaceFromResource({ store }) const { actions: openShortcutActions } = useFileActionsOpenShortcut({ store }) @@ -80,7 +78,6 @@ export const useFileActions = ({ store }: { store?: Store } = {}) => { ...unref(copyActions), ...unref(renameActions), ...unref(createSpaceFromResource), - ...unref(showEditTagsActions), ...unref(restoreActions), ...unref(acceptShareActions), ...unref(hideShareActions), diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsShowEditTags.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsShowEditTags.ts deleted file mode 100644 index e0048f91d64..00000000000 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsShowEditTags.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Store } from 'vuex' -import { eventBus } from '../../../services' -import { useCapabilityFilesTags } from '../../capability' -import { useIsFilesAppActive } from '../helpers' -import { useRouter } from '../../router' -import { useStore } from '../../store' -import { isLocationTrashActive, isLocationPublicActive } from '../../../router' -import { SideBarEventTopics } from '../../sideBar' -import { computed, unref } from 'vue' -import { FileAction, FileActionOptions } from '../types' -import { useGettext } from 'vue3-gettext' - -export const useFileActionsShowEditTags = ({ store }: { store?: Store } = {}) => { - store = store || useStore() - const router = useRouter() - const { $gettext } = useGettext() - const isFilesAppActive = useIsFilesAppActive() - const hasTags = useCapabilityFilesTags() - - const handler = ({ resources }: FileActionOptions) => { - store.commit('Files/SET_FILE_SELECTION', resources) - eventBus.publish(SideBarEventTopics.openWithPanel, 'tags') - } - - const actions = computed((): FileAction[] => [ - { - name: 'show-edit-tags', - icon: 'price-tag-3', - label: () => $gettext('Add or edit tags'), - handler, - isEnabled: ({ resources }) => { - // sidebar is currently only available inside files app - if (!unref(isFilesAppActive) || !unref(hasTags)) { - return false - } - - if (resources[0]?.locked === true) { - return false - } - - if ( - isLocationTrashActive(router, 'files-trash-generic') || - isLocationPublicActive(router, 'files-public-link') - ) { - return false - } - return resources.length === 1 && resources[0].canEditTags() - }, - componentType: 'button', - class: 'oc-files-actions-show-edit-tags-trigger' - } - ]) - - return { - actions - } -} diff --git a/tests/e2e/support/objects/app-files/resource/actions.ts b/tests/e2e/support/objects/app-files/resource/actions.ts index 75aece1d782..019a7d9ed29 100644 --- a/tests/e2e/support/objects/app-files/resource/actions.ts +++ b/tests/e2e/support/objects/app-files/resource/actions.ts @@ -74,7 +74,7 @@ const tagInFilesTable = '//*[contains(@class, "oc-tag")]//span[text()="%s"]//anc const tagInDetailsPanel = '//*[@data-testid="tags"]/td//span[text()="%s"]' const tagInInputForm = '//span[contains(@class, "tags-control-tag")]//span[text()="%s"]//ancestor::span//button[contains(@class, "vs__deselect")]' -const tagFormInput = '#tags-form input' +const tagFormInput = '//*[@data-testid="tags"]//input' const resourcesAsTiles = '#files-view .oc-tiles' const fileVersionSidebar = '#oc-file-versions-sidebar' const noLinkMessage = '#web .oc-link-resolve-error-message' @@ -1329,13 +1329,18 @@ export const addTagsToResource = async (args: resourceTagsArgs): Promise = } await sidebar.open({ page: page, resource: resourceName }) - await sidebar.openPanel({ page: page, name: 'tags' }) - const inputForm = page.locator(tagFormInput) for (const tag of tags) { await inputForm.fill(tag) - await page.locator('.vs__dropdown-option').first().click() + await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().endsWith('tags') && resp.status() === 200 && resp.request().method() === 'PUT' + ), + + page.locator('.vs__dropdown-option').first().press('Enter') + ]) } await sidebar.close({ page }) @@ -1353,7 +1358,6 @@ export const removeTagsFromResource = async (args: resourceTagsArgs): Promise