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