diff --git a/changelog/unreleased/enhancement-create-shortcuts b/changelog/unreleased/enhancement-create-shortcuts
new file mode 100644
index 00000000000..5a844d10aab
--- /dev/null
+++ b/changelog/unreleased/enhancement-create-shortcuts
@@ -0,0 +1,12 @@
+Enhancement: Create shortcuts
+
+We've added a new functionality to add to shortcuts to web, those can be created via the "+ New" context menu.
+Users can enter URLs or pick a file via the drop down menu and create a '.url' file.
+
+'.url' files can be opened via web or downloaded and opened on the desktop.
+
+https://github.com/owncloud/web/pull/9890
+https://github.com/owncloud/web/pull/9908
+https://github.com/owncloud/web/issues/9796
+https://github.com/owncloud/web/issues/9887
+https://github.com/owncloud/web/issues/9850
diff --git a/packages/web-app-files/package.json b/packages/web-app-files/package.json
index 6b243383011..7b63fdfbfd9 100644
--- a/packages/web-app-files/package.json
+++ b/packages/web-app-files/package.json
@@ -35,6 +35,7 @@
"vuex": "4.1.0",
"web-app-files": "workspace:*",
"web-app-search": "workspace:*",
- "web-runtime": "workspace:*"
+ "web-runtime": "workspace:*",
+ "dompurify": "^3.0.6"
}
}
diff --git a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue
index f1eb15bb667..dc8d794fc25 100644
--- a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue
+++ b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue
@@ -170,9 +170,12 @@
+
diff --git a/packages/web-pkg/src/components/FilesList/ContextActions.vue b/packages/web-pkg/src/components/FilesList/ContextActions.vue
index e26a2ab0aca..815f11d2f36 100644
--- a/packages/web-pkg/src/components/FilesList/ContextActions.vue
+++ b/packages/web-pkg/src/components/FilesList/ContextActions.vue
@@ -95,6 +95,7 @@ export default defineComponent({
const menuItemsContext = computed(() => {
const fileHandlers = [
+ ...unref(openShortcutActions),
...unref(editorActions),
...loadExternalAppActions(unref(actionOptions))
]
@@ -112,7 +113,6 @@ export default defineComponent({
const menuItemsActions = computed(() => {
return [
- ...unref(openShortcutActions),
...unref(downloadArchiveActions),
...unref(downloadFileActions),
...unref(deleteActions),
diff --git a/packages/web-pkg/src/components/Search/ResourcePreview.vue b/packages/web-pkg/src/components/Search/ResourcePreview.vue
index 733c31c8f41..5e19dfeac29 100644
--- a/packages/web-pkg/src/components/Search/ResourcePreview.vue
+++ b/packages/web-pkg/src/components/Search/ResourcePreview.vue
@@ -3,14 +3,11 @@
:resource="resource"
:path-prefix="pathPrefix"
:is-path-displayed="true"
- :is-resource-clickable="true"
:folder-link="folderLink"
- :parent-folder-link="parentFolderLink"
:parent-folder-link-icon-additional-attributes="parentFolderLinkIconAdditionalAttributes"
:parent-folder-name="parentFolderName"
:is-thumbnail-displayed="displayThumbnails"
- @click="resourceClicked"
- @click-folder="folderClicked"
+ v-bind="additionalAttrs"
@click-parent-folder="folderClicked"
/>
@@ -21,12 +18,7 @@ import { VisibilityObserver } from '../../observer'
import { debounce } from 'lodash-es'
import { computed, defineComponent, PropType, ref, unref } from 'vue'
import { mapGetters } from 'vuex'
-import {
- useCapabilityShareJailEnabled,
- useGetMatchingSpace,
- useFileActions,
- useFolderLink
-} from '../../composables'
+import { useGetMatchingSpace, useFileActions, useFolderLink } from '../../composables'
import { Resource } from '@ownclouders/web-client/src/helpers'
import { eventBus } from '../../services'
import { isResourceTxtFileAlmostEmpty } from '../../helpers'
@@ -42,11 +34,9 @@ export default defineComponent({
return {}
}
},
- provider: {
- type: Object,
- default: function () {
- return {}
- }
+ isClickable: {
+ type: Boolean,
+ default: true
}
},
setup(props) {
@@ -89,9 +79,22 @@ export default defineComponent({
eventBus.publish('app.search.options-drop.hide')
}
+ const additionalAttrs = computed(() => {
+ if (!props.isClickable) {
+ return {}
+ }
+
+ return {
+ isResourceClickable: true,
+ parentFolderLink: getParentFolderLink(unref(resource)),
+ onClick: resourceClicked,
+ onClickFolder: folderClicked,
+ onClickParentFolder: folderClicked
+ }
+ })
+
return {
space,
- hasShareJail: useCapabilityShareJailEnabled(),
previewData,
resource,
resourceDisabled,
@@ -103,7 +106,8 @@ export default defineComponent({
parentFolderName: getParentFolderName(unref(resource)),
parentFolderLinkIconAdditionalAttributes: getParentFolderLinkIconAdditionalAttributes(
unref(resource)
- )
+ ),
+ additionalAttrs
}
},
computed: {
diff --git a/packages/web-pkg/src/components/Search/types.ts b/packages/web-pkg/src/components/Search/types.ts
index 7b69aab40c6..f4147403520 100644
--- a/packages/web-pkg/src/components/Search/types.ts
+++ b/packages/web-pkg/src/components/Search/types.ts
@@ -1,6 +1,8 @@
+import { SearchResource } from '@ownclouders/web-client/src/webdav/search'
+
export interface SearchResultValue {
id: string | number
- data: unknown
+ data: SearchResource
}
export interface SearchResult {
diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsOpenShortcut.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsOpenShortcut.ts
index 34305f06733..c8605e1082f 100644
--- a/packages/web-pkg/src/composables/actions/files/useFileActionsOpenShortcut.ts
+++ b/packages/web-pkg/src/composables/actions/files/useFileActionsOpenShortcut.ts
@@ -33,13 +33,22 @@ export const useFileActionsOpenShortcut = ({ store }: { store?: Store } = {
}
const handler = async ({ resources, space }: FileActionOptions) => {
try {
+ const webURL = new URL(window.location.href)
const fileContents = (await clientService.webdav.getFileContents(space, resources[0])).body
- const url = extractUrl(fileContents)
+ let url = extractUrl(fileContents)
+
+ // Add protocol if missing
+ url = url.match(/^http[s]?:\/\//) ? url : `https://${url}`
// Omit possible xss code
- const sanitizedUrl = DOMPurify.sanitize(url, { USE_PROFILES: { html: true } })
+ url = DOMPurify.sanitize(url, { USE_PROFILES: { html: true } })
+
+ if (url.startsWith(webURL.origin)) {
+ window.location.href = url
+ return
+ }
- window.open(sanitizedUrl)
+ window.open(url)
} catch (e) {
console.error(e)
store.dispatch('showErrorMessage', {
diff --git a/packages/web-pkg/src/composables/search/index.ts b/packages/web-pkg/src/composables/search/index.ts
index f87cf0102a1..d9a37121ad6 100644
--- a/packages/web-pkg/src/composables/search/index.ts
+++ b/packages/web-pkg/src/composables/search/index.ts
@@ -1 +1,2 @@
export * from './constants'
+export * from './useSearch'
diff --git a/packages/web-pkg/src/composables/search/useSearch.ts b/packages/web-pkg/src/composables/search/useSearch.ts
new file mode 100644
index 00000000000..d921c2c2810
--- /dev/null
+++ b/packages/web-pkg/src/composables/search/useSearch.ts
@@ -0,0 +1,66 @@
+import { computed, unref } from 'vue'
+import { SearchResult } from '../../components'
+import { DavProperties } from '@ownclouders/web-client/src/webdav'
+import { urlJoin } from '@ownclouders/web-client/src/utils'
+import { useConfigurationManager } from '../configuration'
+import { useStore } from '../store'
+import { useClientService } from '../clientService'
+import { isProjectSpaceResource } from '@ownclouders/web-client/src/helpers'
+
+export const useSearch = () => {
+ const store = useStore()
+ const configurationManager = useConfigurationManager()
+ const clientService = useClientService()
+
+ const areHiddenFilesShown = computed(() => store.state.Files?.areHiddenFilesShown)
+ const projectSpaces = computed(() =>
+ store.getters['runtime/spaces/spaces'].filter((s) => isProjectSpaceResource(s))
+ )
+ const getProjectSpace = (id) => {
+ return unref(projectSpaces).find((s) => s.id === id)
+ }
+ const search = async (term: string, searchLimit = null): Promise => {
+ if (configurationManager.options.routing.fullShareOwnerPaths) {
+ await store.dispatch('runtime/spaces/loadMountPoints', {
+ graphClient: clientService.graphAuthenticated
+ })
+ }
+
+ if (!term) {
+ return {
+ totalResults: null,
+ values: []
+ }
+ }
+
+ const { resources, totalResults } = await clientService.webdav.search(term, {
+ searchLimit,
+ davProperties: DavProperties.Default
+ })
+
+ return {
+ totalResults,
+ values: resources
+ .map((resource) => {
+ const matchingSpace = getProjectSpace(resource.parentFolderId)
+ const data = matchingSpace ? matchingSpace : resource
+
+ if (configurationManager.options.routing.fullShareOwnerPaths && data.shareRoot) {
+ data.path = urlJoin(data.shareRoot, data.path)
+ }
+
+ return { id: data.id, data }
+ })
+ .filter(({ data }) => {
+ // filter results if hidden files shouldn't be shown due to settings
+ return !data.name.startsWith('.') || unref(areHiddenFilesShown)
+ })
+ }
+ }
+
+ return {
+ search
+ }
+}
+
+export type SearchFunction = ReturnType['search']
diff --git a/packages/web-pkg/tests/unit/components/CreateShortcutModal.spec.ts b/packages/web-pkg/tests/unit/components/CreateShortcutModal.spec.ts
new file mode 100644
index 00000000000..11688a4ff3c
--- /dev/null
+++ b/packages/web-pkg/tests/unit/components/CreateShortcutModal.spec.ts
@@ -0,0 +1,109 @@
+import CreateShortcutModal from '../../../src/components/CreateShortcutModal.vue'
+import {
+ createStore,
+ defaultComponentMocks,
+ defaultPlugins,
+ defaultStoreMockOptions,
+ mockAxiosReject,
+ RouteLocation,
+ shallowMount
+} from 'web-test-helpers'
+import { SpaceResource } from '@ownclouders/web-client'
+import { mock, mockDeep } from 'jest-mock-extended'
+import { FileResource } from '@ownclouders/web-client/src/helpers'
+import { SearchResource } from '@ownclouders/web-client/src/webdav/search'
+import { ConfigurationManager } from '../../../src'
+
+jest.mock('../../../src/composables/configuration/useConfigurationManager', () => ({
+ useConfigurationManager: () =>
+ mockDeep({
+ options: {
+ routing: {
+ fullShareOwnerPaths: false
+ }
+ }
+ })
+}))
+
+describe('CreateShortcutModal', () => {
+ describe('method "createShortcut"', () => {
+ it('should show message on success', async () => {
+ const { wrapper, storeOptions } = getWrapper()
+ await wrapper.vm.createShortcut('https://owncloud.com', 'owncloud.url')
+ expect(storeOptions.modules.Files.mutations.UPSERT_RESOURCE).toHaveBeenCalled()
+ expect(storeOptions.actions.showMessage).toHaveBeenCalled()
+ })
+ it('should show error message on fail', async () => {
+ console.error = jest.fn()
+ const { wrapper, storeOptions } = getWrapper({ rejectPutFileContents: true })
+ await wrapper.vm.createShortcut('https://owncloud.com', 'owncloud.url')
+ expect(storeOptions.modules.Files.mutations.UPSERT_RESOURCE).not.toHaveBeenCalled()
+ expect(storeOptions.actions.showErrorMessage).toHaveBeenCalled()
+ })
+ })
+ describe('method "searchTask"', () => {
+ it('should set "searchResult" correctly', async () => {
+ const { wrapper } = getWrapper()
+ await wrapper.vm.searchTask.perform('new file')
+ expect(wrapper.vm.searchResult.values.length).toBe(3)
+ })
+ it('should reset "searchResult" on error', async () => {
+ console.error = jest.fn()
+ const { wrapper } = getWrapper({ rejectSearch: true })
+ await wrapper.vm.searchTask.perform('new folder')
+ expect(wrapper.vm.searchResult).toBe(null)
+ })
+ })
+})
+
+function getWrapper({ rejectPutFileContents = false, rejectSearch = false } = {}) {
+ const storeOptions = {
+ ...defaultStoreMockOptions
+ }
+
+ storeOptions.modules.Files.getters.currentFolder.mockImplementation(() => mock())
+
+ const store = createStore(storeOptions)
+
+ const mocks = {
+ ...defaultComponentMocks({
+ currentRoute: mock({ name: 'files-spaces-generic' })
+ })
+ }
+
+ if (rejectPutFileContents) {
+ mocks.$clientService.webdav.putFileContents.mockRejectedValue(() => mockAxiosReject())
+ } else {
+ mocks.$clientService.webdav.putFileContents.mockResolvedValue(mock())
+ }
+
+ if (rejectSearch) {
+ mocks.$clientService.webdav.search.mockRejectedValue(() => mockAxiosReject())
+ } else {
+ mocks.$clientService.webdav.search.mockResolvedValue({
+ resources: [
+ mock({ name: 'New File' }),
+ mock({ name: 'New File (1)' }),
+ mock({ name: 'New Folder' })
+ ],
+ totalResults: 3
+ })
+ }
+
+ return {
+ mocks,
+ storeOptions,
+ wrapper: shallowMount(CreateShortcutModal, {
+ props: {
+ cancel: jest.fn(),
+ space: mock(),
+ title: 'Personal quota'
+ },
+ global: {
+ plugins: [...defaultPlugins(), store],
+ mocks,
+ provide: mocks
+ }
+ })
+ }
+}
diff --git a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsOpenShortcut.spec.ts b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsOpenShortcut.spec.ts
index 1a8e02c8549..24cd11ee8ab 100644
--- a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsOpenShortcut.spec.ts
+++ b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsOpenShortcut.spec.ts
@@ -7,7 +7,7 @@ import {
RouteLocation,
getComposableWrapper
} from 'web-test-helpers'
-import { useFileActionsOpenShortcut, useRoute } from '../../../../../src'
+import { ConfigurationManager, useFileActionsOpenShortcut, useRoute } from '../../../../../src'
import { Resource } from '@ownclouders/web-client'
import { GetFileContentsResponse } from '@ownclouders/web-client/src/webdav/getFileContents'
@@ -16,10 +16,17 @@ jest.mock('../../../../../src/composables/router', () => ({
useRoute: jest.fn()
}))
+jest.mock('../../../../../src/composables/configuration', () => ({
+ useConfigurationManager: () =>
+ mock({
+ serverUrl: 'https://demo.owncloud.com'
+ })
+}))
+
window = Object.create(window)
Object.defineProperty(window, 'location', {
value: {
- href: ''
+ href: 'https://demo.owncloud.com'
},
writable: true
})
@@ -55,6 +62,18 @@ describe('openShortcut', () => {
})
})
describe('method "handler"', () => {
+ it('adds http(s) protocol if missing and opens the url in a new tab', () => {
+ getWrapper({
+ getFileContentsValue: '[InternetShortcut]\nURL=owncloud.com',
+ setup: async ({ actions }) => {
+ await unref(actions)[0].handler({
+ resources: [mock()],
+ space: null
+ })
+ expect(window.open).toHaveBeenCalledWith('https://owncloud.com')
+ }
+ })
+ })
it('omits xss code and opens the url in a new tab', () => {
getWrapper({
getFileContentsValue:
@@ -68,6 +87,18 @@ describe('openShortcut', () => {
}
})
})
+ it('opens the url in the same window if url links to OCIS instance', () => {
+ getWrapper({
+ getFileContentsValue: '[InternetShortcut]\nURL=https://demo.owncloud.com',
+ setup: async ({ actions }) => {
+ await unref(actions)[0].handler({
+ resources: [mock()],
+ space: null
+ })
+ expect(window.location.href).toBe('https://demo.owncloud.com')
+ }
+ })
+ })
})
})
describe('method "extractUrl"', () => {
@@ -108,7 +139,6 @@ function getWrapper({
})
}
- // url contains xss code to test xss protection
mocks.$clientService.webdav.getFileContents.mockResolvedValue(
mock({
body: getFileContentsValue
diff --git a/packages/web-pkg/tests/unit/composables/search/useSearch.spec.ts b/packages/web-pkg/tests/unit/composables/search/useSearch.spec.ts
new file mode 100644
index 00000000000..77fdf2e9604
--- /dev/null
+++ b/packages/web-pkg/tests/unit/composables/search/useSearch.spec.ts
@@ -0,0 +1,92 @@
+import { mock } from 'jest-mock-extended'
+import {
+ createStore,
+ defaultComponentMocks,
+ defaultStoreMockOptions,
+ getComposableWrapper
+} from 'web-test-helpers'
+import { useSearch } from '../../../../src/composables'
+import { ConfigurationManager } from '../../../../src/configuration'
+
+jest.mock('../../../../src/composables/configuration', () => ({
+ useConfigurationManager: () =>
+ mock({
+ options: {
+ routing: {
+ fullShareOwnerPaths: false,
+ idBased: true
+ }
+ }
+ })
+}))
+
+describe('useSearch', () => {
+ describe('method "search"', () => {
+ it('can search', async () => {
+ const files = [
+ { id: 'foo', name: 'foo', fileInfo: {} },
+ { id: 'bar', name: 'bar', fileInfo: {} },
+ { id: 'baz', name: 'baz', fileInfo: {} }
+ ]
+
+ const wrapper = createWrapper({ resources: files })
+
+ const noTermResult = await wrapper.vm.search('')
+ expect(noTermResult).toEqual({ totalResults: null, values: [] })
+
+ const withTermResult = await wrapper.vm.search('foo')
+ expect(withTermResult.values.map((r) => r.data)).toMatchObject(files)
+ })
+ it('properly returns space resources', async () => {
+ const files = [{ id: 'foo', name: 'foo', parentFolderId: '2' }]
+
+ const wrapper = createWrapper({ resources: files })
+
+ const withTerm = await wrapper.vm.search('foo')
+ expect(withTerm.values.map((r) => r.data)[0].id).toEqual('2')
+ })
+ })
+})
+
+const createWrapper = ({ resources = [] }: { resources?: any[] } = {}) => {
+ const storeOptions = { ...defaultStoreMockOptions }
+ storeOptions.getters.capabilities.mockImplementation(() => ({
+ spaces: { projects: true, share_jail: true }
+ }))
+ storeOptions.modules.runtime.modules.spaces.getters.spaces = jest.fn(() => [
+ {
+ id: '1',
+ fileId: '1',
+ driveType: 'personal',
+ getDriveAliasAndItem: () => 'personal/admin'
+ },
+ {
+ id: '2',
+ driveType: 'project',
+ name: 'New space',
+ getDriveAliasAndItem: jest.fn()
+ }
+ ])
+ const store = createStore(storeOptions)
+ const mocks = defaultComponentMocks({})
+
+ mocks.$clientService.webdav.search.mockResolvedValue({
+ resources,
+ totalResults: resources.length
+ })
+
+ return getComposableWrapper(
+ () => {
+ const { search } = useSearch()
+
+ return {
+ search
+ }
+ },
+ {
+ mocks,
+ provide: mocks,
+ store
+ }
+ )
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 20feb57eba4..afb7fa406ff 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -729,6 +729,9 @@ importers:
design-system:
specifier: workspace:@ownclouders/design-system@*
version: link:../design-system
+ dompurify:
+ specifier: ^3.0.6
+ version: 3.0.6
filesize:
specifier: ^9.0.11
version: 9.0.11