* along with this program. If not, see <http://www.gnu.org/licenses/>. | * along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
* | * | ||||
*/ | */ | ||||
import { Permission, type Node, View, registerFileAction, FileAction } from '@nextcloud/files' | |||||
import { Permission, type Node, View, registerFileAction, FileAction, FileType } from '@nextcloud/files' | |||||
import { translate as t } from '@nextcloud/l10n' | import { translate as t } from '@nextcloud/l10n' | ||||
import InformationSvg from '@mdi/svg/svg/information-variant.svg?raw' | import InformationSvg from '@mdi/svg/svg/information-variant.svg?raw' | ||||
return (nodes[0].root?.startsWith('/files/') && nodes[0].permissions !== Permission.NONE) ?? false | return (nodes[0].root?.startsWith('/files/') && nodes[0].permissions !== Permission.NONE) ?? false | ||||
}, | }, | ||||
async exec(node: Node, view: View) { | |||||
async exec(node: Node, view: View, dir: string) { | |||||
try { | try { | ||||
// TODO: migrate Sidebar to use a Node instead | // TODO: migrate Sidebar to use a Node instead | ||||
await window.OCA.Files.Sidebar.open(node.path) | await window.OCA.Files.Sidebar.open(node.path) | ||||
window.OCP.Files.Router.goToRoute( | window.OCP.Files.Router.goToRoute( | ||||
null, | null, | ||||
{ view: view.id, fileid: node.fileid }, | { view: view.id, fileid: node.fileid }, | ||||
{ dir: node.dirname }, | |||||
{ dir }, | |||||
true, | true, | ||||
) | ) | ||||
</template> | </template> | ||||
<script lang='ts'> | <script lang='ts'> | ||||
import type { PropType } from 'vue' | |||||
import type { Node } from '@nextcloud/files' | |||||
import { CancelablePromise } from 'cancelable-promise' | import { CancelablePromise } from 'cancelable-promise' | ||||
import { debounce } from 'debounce' | import { debounce } from 'debounce' | ||||
import { emit } from '@nextcloud/event-bus' | import { emit } from '@nextcloud/event-bus' | ||||
import { extname } from 'path' | import { extname } from 'path' | ||||
import { generateUrl } from '@nextcloud/router' | import { generateUrl } from '@nextcloud/router' | ||||
import { getFileActions, DefaultType, FileType, formatFileSize, Permission, NodeStatus } from '@nextcloud/files' | |||||
import { getFileActions, DefaultType, FileType, formatFileSize, Permission, Folder, File } from '@nextcloud/files' | |||||
import { showError, showSuccess } from '@nextcloud/dialogs' | import { showError, showSuccess } from '@nextcloud/dialogs' | ||||
import { translate } from '@nextcloud/l10n' | import { translate } from '@nextcloud/l10n' | ||||
import { vOnClickOutside } from '@vueuse/components' | import { vOnClickOutside } from '@vueuse/components' | ||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' | import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' | ||||
import Vue from 'vue' | import Vue from 'vue' | ||||
import { ACTION_DETAILS } from '../actions/sidebarAction.ts' | |||||
import { action as sidebarAction } from '../actions/sidebarAction.ts' | |||||
import { hashCode } from '../utils/hashUtils.ts' | import { hashCode } from '../utils/hashUtils.ts' | ||||
import { isCachedPreview } from '../services/PreviewService.ts' | import { isCachedPreview } from '../services/PreviewService.ts' | ||||
import { useActionsMenuStore } from '../store/actionsmenu.ts' | import { useActionsMenuStore } from '../store/actionsmenu.ts' | ||||
default: false, | default: false, | ||||
}, | }, | ||||
source: { | source: { | ||||
type: Object, | |||||
type: [Folder, File, Node] as PropType<Node>, | |||||
required: true, | required: true, | ||||
}, | }, | ||||
index: { | index: { | ||||
required: true, | required: true, | ||||
}, | }, | ||||
nodes: { | nodes: { | ||||
type: Array, | |||||
type: Array as PropType<Node[]>, | |||||
required: true, | required: true, | ||||
}, | }, | ||||
filesListWidth: { | filesListWidth: { | ||||
currentDir() { | currentDir() { | ||||
// Remove any trailing slash but leave root slash | // Remove any trailing slash but leave root slash | ||||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') | |||||
return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') | |||||
}, | }, | ||||
currentFileId() { | currentFileId() { | ||||
return this.$route.params.fileid || this.$route.query.fileid || null | return this.$route.params.fileid || this.$route.query.fileid || null | ||||
}, | }, | ||||
openDetailsIfAvailable(event) { | openDetailsIfAvailable(event) { | ||||
const detailsAction = this.enabledActions.find(action => action.id === ACTION_DETAILS) | |||||
if (detailsAction) { | |||||
event.preventDefault() | |||||
event.stopPropagation() | |||||
detailsAction.exec(this.source, this.currentView) | |||||
event.preventDefault() | |||||
event.stopPropagation() | |||||
if (sidebarAction?.enabled?.([this.source], this.currentView)) { | |||||
sidebarAction.exec(this.source, this.currentView, this.currentDir) | |||||
} | } | ||||
}, | }, | ||||
</template> | </template> | ||||
<script lang="ts"> | <script lang="ts"> | ||||
import type { PropType } from 'vue' | |||||
import type { Node } from '@nextcloud/files' | |||||
import { translate, translatePlural } from '@nextcloud/l10n' | import { translate, translatePlural } from '@nextcloud/l10n' | ||||
import { getFileListHeaders, type Node } from '@nextcloud/files' | |||||
import { getFileListHeaders, Folder, View } from '@nextcloud/files' | |||||
import { showError } from '@nextcloud/dialogs' | import { showError } from '@nextcloud/dialogs' | ||||
import Vue from 'vue' | import Vue from 'vue' | ||||
import VirtualList from './VirtualList.vue' | |||||
import { action as sidebarAction } from '../actions/sidebarAction.ts' | import { action as sidebarAction } from '../actions/sidebarAction.ts' | ||||
import FileEntry from './FileEntry.vue' | import FileEntry from './FileEntry.vue' | ||||
import FilesListTableHeader from './FilesListTableHeader.vue' | import FilesListTableHeader from './FilesListTableHeader.vue' | ||||
import filesListWidthMixin from '../mixins/filesListWidth.ts' | import filesListWidthMixin from '../mixins/filesListWidth.ts' | ||||
import logger from '../logger.js' | import logger from '../logger.js' | ||||
import VirtualList from './VirtualList.vue' | |||||
export default Vue.extend({ | export default Vue.extend({ | ||||
name: 'FilesListVirtual', | name: 'FilesListVirtual', | ||||
props: { | props: { | ||||
currentView: { | currentView: { | ||||
type: Object, | |||||
type: View, | |||||
required: true, | required: true, | ||||
}, | }, | ||||
currentFolder: { | currentFolder: { | ||||
type: Object, | |||||
type: Folder, | |||||
required: true, | required: true, | ||||
}, | }, | ||||
nodes: { | nodes: { | ||||
type: Array, | |||||
type: Array as PropType<Node[]>, | |||||
required: true, | required: true, | ||||
}, | }, | ||||
}, | }, | ||||
const node = this.nodes.find(n => n.fileid === this.fileId) as Node | const node = this.nodes.find(n => n.fileid === this.fileId) as Node | ||||
if (node && sidebarAction?.enabled?.([node], this.currentView)) { | if (node && sidebarAction?.enabled?.([node], this.currentView)) { | ||||
logger.debug('Opening sidebar on file ' + node.path, { node }) | logger.debug('Opening sidebar on file ' + node.path, { node }) | ||||
sidebarAction.exec(node, this.currentView, this.currentFolder) | |||||
sidebarAction.exec(node, this.currentView, this.currentFolder.path) | |||||
} | } | ||||
} | } | ||||
}, | }, |
iconSvgInline: FolderPlusSvg, | iconSvgInline: FolderPlusSvg, | ||||
async handler(context: Folder, content: Node[]) { | async handler(context: Folder, content: Node[]) { | ||||
const contentNames = content.map((node: Node) => node.basename) | const contentNames = content.map((node: Node) => node.basename) | ||||
const name = getUniqueName(t('files', 'New Folder'), contentNames) | |||||
const name = getUniqueName(t('files', 'New folder'), contentNames) | |||||
const { fileid, source } = await createNewFolder(context.source, name) | const { fileid, source } = await createNewFolder(context.source, name) | ||||
// Create the folder in the store | // Create the folder in the store |
<!-- Current folder breadcrumbs --> | <!-- Current folder breadcrumbs --> | ||||
<BreadCrumbs :path="dir" @reload="fetchContent"> | <BreadCrumbs :path="dir" @reload="fetchContent"> | ||||
<template #actions> | <template #actions> | ||||
<NcButton v-if="canShare" | |||||
:aria-label="shareButtonLabel" | |||||
:class="{ 'files-list__header-share-button--shared': shareButtonType }" | |||||
:title="shareButtonLabel" | |||||
class="files-list__header-share-button" | |||||
type="tertiary" | |||||
@click="openSharingSidebar"> | |||||
<template #icon> | |||||
<LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" /> | |||||
<ShareVariantIcon v-else :size="20" /> | |||||
</template> | |||||
</NcButton> | |||||
<!-- Uploader --> | <!-- Uploader --> | ||||
<UploadPicker v-if="currentFolder" | |||||
<UploadPicker v-if="currentFolder && canUpload" | |||||
:content="dirContents" | :content="dirContents" | ||||
:destination="currentFolder" | :destination="currentFolder" | ||||
:multiple="true" | :multiple="true" | ||||
import type { UserConfig } from '../types.ts' | import type { UserConfig } from '../types.ts' | ||||
import type { View, ContentsWithRoot } from '@nextcloud/files' | import type { View, ContentsWithRoot } from '@nextcloud/files' | ||||
import { Folder, Node } from '@nextcloud/files' | |||||
import { Folder, Node, Permission } from '@nextcloud/files' | |||||
import { getCapabilities } from '@nextcloud/capabilities' | |||||
import { join, dirname } from 'path' | import { join, dirname } from 'path' | ||||
import { orderBy } from 'natural-orderby' | import { orderBy } from 'natural-orderby' | ||||
import { translate } from '@nextcloud/l10n' | import { translate } from '@nextcloud/l10n' | ||||
import { UploadPicker } from '@nextcloud/upload' | import { UploadPicker } from '@nextcloud/upload' | ||||
import { Type } from '@nextcloud/sharing' | |||||
import Vue from 'vue' | |||||
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js' | import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js' | ||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' | import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' | ||||
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' | import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' | ||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' | import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' | ||||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' | import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' | ||||
import Vue from 'vue' | |||||
import LinkIcon from 'vue-material-design-icons/Link.vue' | |||||
import ShareVariantIcon from 'vue-material-design-icons/ShareVariant.vue' | |||||
import { action as sidebarAction } from '../actions/sidebarAction.ts' | |||||
import { useFilesStore } from '../store/files.ts' | import { useFilesStore } from '../store/files.ts' | ||||
import { usePathsStore } from '../store/paths.ts' | import { usePathsStore } from '../store/paths.ts' | ||||
import { useSelectionStore } from '../store/selection.ts' | import { useSelectionStore } from '../store/selection.ts' | ||||
import filesSortingMixin from '../mixins/filesSorting.ts' | import filesSortingMixin from '../mixins/filesSorting.ts' | ||||
import logger from '../logger.js' | import logger from '../logger.js' | ||||
const isSharingEnabled = getCapabilities()?.files_sharing !== undefined | |||||
export default Vue.extend({ | export default Vue.extend({ | ||||
name: 'FilesList', | name: 'FilesList', | ||||
components: { | components: { | ||||
BreadCrumbs, | BreadCrumbs, | ||||
FilesListVirtual, | FilesListVirtual, | ||||
LinkIcon, | |||||
NcAppContent, | NcAppContent, | ||||
NcButton, | NcButton, | ||||
NcEmptyContent, | NcEmptyContent, | ||||
NcIconSvgWrapper, | NcIconSvgWrapper, | ||||
NcLoadingIcon, | NcLoadingIcon, | ||||
ShareVariantIcon, | |||||
UploadPicker, | UploadPicker, | ||||
}, | }, | ||||
return { | return { | ||||
loading: true, | loading: true, | ||||
promise: null, | promise: null, | ||||
Type, | |||||
} | } | ||||
}, | }, | ||||
*/ | */ | ||||
dir(): string { | dir(): string { | ||||
// Remove any trailing slash but leave root slash | // Remove any trailing slash but leave root slash | ||||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') | |||||
return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') | |||||
}, | }, | ||||
/** | /** | ||||
const dir = this.dir.split('/').slice(0, -1).join('/') || '/' | const dir = this.dir.split('/').slice(0, -1).join('/') || '/' | ||||
return { ...this.$route, query: { dir } } | return { ...this.$route, query: { dir } } | ||||
}, | }, | ||||
shareAttributes(): number[]|undefined { | |||||
if (!this.currentFolder?.attributes?.['share-types']) { | |||||
return undefined | |||||
} | |||||
return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[] | |||||
}, | |||||
shareButtonLabel() { | |||||
if (!this.shareAttributes) { | |||||
return this.t('files', 'Share') | |||||
} | |||||
if (this.shareButtonType === Type.SHARE_TYPE_LINK) { | |||||
return this.t('files', 'Shared by link') | |||||
} | |||||
return this.t('files', 'Shared') | |||||
}, | |||||
shareButtonType(): Type|null { | |||||
if (!this.shareAttributes) { | |||||
return null | |||||
} | |||||
// If all types are links, show the link icon | |||||
if (this.shareAttributes.some(type => type === Type.SHARE_TYPE_LINK)) { | |||||
return Type.SHARE_TYPE_LINK | |||||
} | |||||
return Type.SHARE_TYPE_USER | |||||
}, | |||||
canUpload() { | |||||
return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0 | |||||
}, | |||||
canShare() { | |||||
return isSharingEnabled | |||||
&& this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0 | |||||
}, | |||||
}, | }, | ||||
watch: { | watch: { | ||||
} | } | ||||
}, | }, | ||||
openSharingSidebar() { | |||||
if (window?.OCA?.Files?.Sidebar?.setActiveTab) { | |||||
window.OCA.Files.Sidebar.setActiveTab('sharing') | |||||
} | |||||
sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path) | |||||
}, | |||||
t: translate, | t: translate, | ||||
}, | }, | ||||
}) | }) | ||||
// Only the breadcrumbs shrinks | // Only the breadcrumbs shrinks | ||||
flex: 0 0; | flex: 0 0; | ||||
} | } | ||||
&-share-button { | |||||
opacity: .3; | |||||
&--shared { | |||||
opacity: 1; | |||||
} | |||||
} | |||||
} | } | ||||
&__refresh-icon { | &__refresh-icon { | ||||
flex: 0 0 44px; | flex: 0 0 44px; | ||||
width: 44px; | width: 44px; | ||||
height: 44px; | height: 44px; | ||||
} | } | ||||
&__loading-icon { | &__loading-icon { | ||||
margin: auto; | margin: auto; | ||||
} | } |
namespace OCA\ShareByMail; | namespace OCA\ShareByMail; | ||||
use OCA\ShareByMail\Settings\SettingsManager; | use OCA\ShareByMail\Settings\SettingsManager; | ||||
use OCP\App\IAppManager; | |||||
use OCP\Capabilities\ICapability; | use OCP\Capabilities\ICapability; | ||||
use OCP\Share\IManager; | use OCP\Share\IManager; | ||||
/** @var SettingsManager */ | /** @var SettingsManager */ | ||||
private $settingsManager; | private $settingsManager; | ||||
/** @var IAppManager */ | |||||
private $appManager; | |||||
public function __construct(IManager $manager, | public function __construct(IManager $manager, | ||||
SettingsManager $settingsManager) { | |||||
SettingsManager $settingsManager, | |||||
IAppManager $appManager) { | |||||
$this->manager = $manager; | $this->manager = $manager; | ||||
$this->settingsManager = $settingsManager; | $this->settingsManager = $settingsManager; | ||||
$this->appManager = $appManager; | |||||
} | } | ||||
/** | /** | ||||
* }, | * }, | ||||
* } | * } | ||||
* } | * } | ||||
* } | |||||
* }|array<empty> | |||||
*/ | */ | ||||
public function getCapabilities(): array { | public function getCapabilities(): array { | ||||
if (!$this->appManager->isEnabledForUser('files_sharing')) { | |||||
return []; | |||||
} | |||||
return [ | return [ | ||||
'files_sharing' => | 'files_sharing' => | ||||
[ | [ |
use OCA\ShareByMail\Capabilities; | use OCA\ShareByMail\Capabilities; | ||||
use OCA\ShareByMail\Settings\SettingsManager; | use OCA\ShareByMail\Settings\SettingsManager; | ||||
use OCP\App\IAppManager; | |||||
use OCP\Share\IManager; | use OCP\Share\IManager; | ||||
use Test\TestCase; | use Test\TestCase; | ||||
/** @var IManager | \PHPUnit\Framework\MockObject\MockObject */ | /** @var IManager | \PHPUnit\Framework\MockObject\MockObject */ | ||||
private $settingsManager; | private $settingsManager; | ||||
/** @var IAppManager | \PHPUnit\Framework\MockObject\MockObject */ | |||||
private $appManager; | |||||
protected function setUp(): void { | protected function setUp(): void { | ||||
parent::setUp(); | parent::setUp(); | ||||
$this->manager = $this::createMock(IManager::class); | $this->manager = $this::createMock(IManager::class); | ||||
$this->settingsManager = $this::createMock(SettingsManager::class); | $this->settingsManager = $this::createMock(SettingsManager::class); | ||||
$this->capabilities = new Capabilities($this->manager, $this->settingsManager); | |||||
$this->appManager = $this::createMock(IAppManager::class); | |||||
$this->capabilities = new Capabilities($this->manager, $this->settingsManager, $this->appManager); | |||||
} | } | ||||
public function testGetCapabilities() { | public function testGetCapabilities() { | ||||
->willReturn(false); | ->willReturn(false); | ||||
$this->settingsManager->method('sendPasswordByMail') | $this->settingsManager->method('sendPasswordByMail') | ||||
->willReturn(true); | ->willReturn(true); | ||||
$this->appManager->method('isEnabledForUser') | |||||
->willReturn(true); | |||||
$capabilities = [ | $capabilities = [ | ||||
'files_sharing' => | 'files_sharing' => |
}) | }) | ||||
it('Opens the versions panel and sees the versions', () => { | it('Opens the versions panel and sees the versions', () => { | ||||
cy.visit('/apps/files') | |||||
openVersionsPanel(randomFileName) | openVersionsPanel(randomFileName) | ||||
cy.get('#tab-version_vue').within(() => { | cy.get('#tab-version_vue').within(() => { |