Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/files_sharing/lib/Listener/LoadSidebarListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public function handle(Event $event): void {
return;
}
Util::addScript(Application::APP_ID, 'files_sharing_tab', 'files');
// Vue 3 bridge exposing the unified sharing dialog on OCA.Sharing for the
// (Vue 2) sidebar to trigger without bundling Vue 3.
Util::addStyle(Application::APP_ID, 'sharing-dialog');
Util::addScript(Application::APP_ID, 'sharing-dialog', 'files');

$appConfig = Server::get(IAppConfig::class);
$gsConfig = Server::get(IConfig::class);
Expand Down
72 changes: 72 additions & 0 deletions apps/files_sharing/src/components/AvatarStack.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<div class="avatar-stack">
<span
v-for="(recipient, index) in displayed"
:key="recipient.class + recipient.value"
class="avatar-stack__item"
:style="{ zIndex: displayed.length - index }">
<NcAvatar
:size="32"
:isNoUser="isNoUserRecipient(recipient)"
:user="isNoUserRecipient(recipient) ? undefined : recipient.value"
:displayName="recipient.display_name"
disableMenu
disableTooltip />
</span>
<span v-if="overflow > 0" class="avatar-stack__overflow" :aria-hidden="true">
+{{ overflow }}
</span>
</div>
</template>

<script setup lang="ts">
import type { SharingRecipient } from '../types/unifiedSharing.ts'

import { computed } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import { isNoUserRecipient } from '../lib/unifiedSharing.ts'

const props = defineProps<{
recipients: SharingRecipient[]
}>()

const MAX_AVATARS = 3

const displayed = computed(() => props.recipients.slice(0, MAX_AVATARS))
const overflow = computed(() => Math.max(0, props.recipients.length - MAX_AVATARS))
</script>

<style lang="scss" scoped>
.avatar-stack {
display: flex;
align-items: center;

// Wrapper we own, so the ring cannot be overridden by the avatar's own styles.
&__item {
display: flex;
border-radius: 50%;
// Ring in the main background colour separates overlapping avatars. Using
// a box-shadow (not a border) keeps the avatar exactly 32px, matching the
// avatars in the other entries.
box-shadow: 0 0 0 2px var(--color-main-background);
// Each avatar sits under the previous one (first on top); z-index is set
// inline, descending, so the ring overlaps correctly.
position: relative;

&:not(:first-child) {
margin-inline-start: -12px;
}
}

&__overflow {
margin-inline-start: 4px;
color: var(--color-text-maxcontrast);
font-size: 12px;
}
}
</style>
26 changes: 25 additions & 1 deletion apps/files_sharing/src/components/SharingEntryLink.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
{{ subtitle }}
</p>
<SharingEntryQuickShareSelect
v-if="share && share.permissions !== undefined"
v-if="share && share.permissions !== undefined && !config.sharingDialogEnabled"
:share="share"
:file-info="fileInfo"
@open-sharing-details="openShareDetailsForCustomSettings(share)" />
Expand Down Expand Up @@ -151,6 +151,7 @@
<template v-if="share">
<template v-if="share.canEdit && canReshare">
<NcActionButton
v-if="!config.sharingDialogEnabled"
:disabled="saving"
:close-after-click="true"
@click.prevent="openSharingDetails">
Expand All @@ -159,6 +160,16 @@
</template>
{{ t('files_sharing', 'Customize link') }}
</NcActionButton>
<NcActionButton
v-else
:disabled="saving"
:close-after-click="true"
@click.prevent="openEditDialog">
<template #icon>
<Tune :size="20" />
</template>
{{ t('files_sharing', 'Customize link') }}
</NcActionButton>
</template>

<NcActionButton
Expand Down Expand Up @@ -279,6 +290,7 @@ import ShareDetails from '../mixins/ShareDetails.js'
import SharesMixin from '../mixins/SharesMixin.js'
import Share from '../models/Share.ts'
import logger from '../services/logger.ts'
import { openShareEditDialog } from '../services/SharingDialog.ts'
import GeneratePassword from '../utils/GeneratePassword.ts'

export default {
Expand Down Expand Up @@ -599,6 +611,17 @@ export default {
},

methods: {
/**
* Open the unified sharing dialog to edit this link share.
*/
async openEditDialog() {
try {
await openShareEditDialog(this.share.id, this.fileInfo.node)
} catch (error) {
logger.error('Failed to open the sharing dialog', { error })
}
},

/**
* Check if the share requires review
*
Expand Down Expand Up @@ -866,6 +889,7 @@ export default {
justify-content: space-between;
flex: 1 0;
min-width: 0;
align-items: center;
}

&__desc {
Expand Down
10 changes: 10 additions & 0 deletions apps/files_sharing/src/components/SharingEntrySimple.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
{{ subtitle }}
</p>
</div>
<!-- Standalone action(s) shown before the overflow menu (e.g. a caret) -->
<slot name="action" />
<NcActions
v-if="$slots['default']"
ref="actionsComponent"
class="sharing-entry__actions"
menu-align="right"
:force-menu="forceMenu"
:aria-expanded="ariaExpandedValue">
<slot />
</NcActions>
Expand Down Expand Up @@ -53,6 +56,13 @@ export default {
type: Boolean,
default: null,
},

// Force the overflow menu even with a single action (keeps destructive
// actions in a menu instead of rendering them inline).
forceMenu: {
type: Boolean,
default: false,
},
},

computed: {
Expand Down
160 changes: 160 additions & 0 deletions apps/files_sharing/src/components/UnifiedShareEntry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UnifiedShareEntry from './UnifiedShareEntry.vue'
import { openShareEditDialog } from '../services/SharingDialog.ts'
import { deleteShare, removeRecipient } from '../services/unifiedShares.ts'

vi.mock('../services/SharingDialog.ts', () => ({
openShareEditDialog: vi.fn().mockResolvedValue(undefined),
}))

vi.mock('../services/unifiedShares.ts', () => ({
deleteShare: vi.fn().mockResolvedValue(undefined),
removeRecipient: vi.fn().mockResolvedValue(undefined),
}))

vi.mock('../services/logger.ts', () => ({
default: { error: vi.fn(), debug: vi.fn() },
}))

// Confirm dialog: answers with the last button ("Delete") by default; set
// `confirmation.declined` to answer with the first one ("Cancel") instead.
const confirmation = vi.hoisted(() => ({ declined: false }))

vi.mock('@nextcloud/dialogs', () => ({
DialogBuilder: class {
buttons: { callback: () => void }[] = []
setName() {
return this
}

setText() {
return this
}

setButtons(buttons: { callback: () => void }[]) {
this.buttons = buttons
return this
}

build() {
const { buttons } = this
return {
show: async () => (confirmation.declined ? buttons.at(0) : buttons.at(-1))?.callback(),
}
}
},
}))

function recipient(value: string) {
return {
class: 'UserRecipient',
value,
instance: null,
display_name: value,
icon: null,
secret: { updatable: false },
initiator: null,
permissions: [],
}
}

function share(recipients = [recipient('bob')]) {
return {
id: '42',
state: 'active',
recipients,
permissions: [],
permission_preset: null,
owner: { user_id: 'alice', display_name: 'Alice', instance: null },
}
}

function mountEntry(data = share()) {
return mount(UnifiedShareEntry, {
propsData: { share: data, fileInfo: { node: { fileid: 1 } } },
stubs: {
NcAvatar: true,
AvatarStack: true,
// The actions live in a menu that only renders its content once opened.
NcActions: { template: '<div><slot /></div>' },
},
})
}

/** Trigger a row action by its label, as clicking the menu entry would. */
async function triggerAction(wrapper: ReturnType<typeof mountEntry>, label: string) {
const action = wrapper.findAllComponents({ name: 'NcActionButton' })
.wrappers.find((button) => button.text().includes(label))
expect(action, `the "${label}" action is rendered`).toBeDefined()
action!.vm.$emit('click')
await new Promise((resolve) => setTimeout(resolve))
}

beforeEach(() => {
vi.clearAllMocks()
confirmation.declined = false
})

describe('editing', () => {
it('refreshes the list once the dialog closes', async () => {
const wrapper = mountEntry()
await triggerAction(wrapper, 'Edit share')
expect(openShareEditDialog).toHaveBeenCalledWith('42', { fileid: 1 })
expect(wrapper.emitted('refresh')).toHaveLength(1)
})

it('still refreshes when the dialog fails', async () => {
vi.mocked(openShareEditDialog).mockRejectedValueOnce(new Error('nope'))
const wrapper = mountEntry()
await triggerAction(wrapper, 'Edit share')
// The dialog writes straight to the backend, so it may have applied
// changes before it errored.
expect(wrapper.emitted('refresh')).toHaveLength(1)
})
})

describe('deleting the share', () => {
it('deletes it and refreshes once confirmed', async () => {
const wrapper = mountEntry()
await triggerAction(wrapper, 'Delete share')
expect(deleteShare).toHaveBeenCalledWith('42')
expect(wrapper.emitted('refresh')).toHaveLength(1)
})

it('does not delete it when the confirmation is declined', async () => {
confirmation.declined = true
const wrapper = mountEntry()
await triggerAction(wrapper, 'Delete share')
expect(deleteShare).not.toHaveBeenCalled()
expect(wrapper.emitted('refresh')).toBeUndefined()
})

it('does not refresh when the deletion fails', async () => {
vi.mocked(deleteShare).mockRejectedValueOnce(new Error('nope'))
const wrapper = mountEntry()
await triggerAction(wrapper, 'Delete share')
expect(wrapper.emitted('refresh')).toBeUndefined()
})
})

describe('removing a participant', () => {
it('removes the recipient of the row it was triggered on', async () => {
const wrapper = mountEntry(share([recipient('bob'), recipient('carol')]))
await triggerAction(wrapper, 'Remove participant')
expect(removeRecipient).toHaveBeenCalledWith('42', 'UserRecipient', 'bob', null)
expect(wrapper.emitted('refresh')).toHaveLength(1)
})

it('does not refresh when the removal fails', async () => {
vi.mocked(removeRecipient).mockRejectedValueOnce(new Error('nope'))
const wrapper = mountEntry(share([recipient('bob'), recipient('carol')]))
await triggerAction(wrapper, 'Remove participant')
expect(wrapper.emitted('refresh')).toBeUndefined()
})
})
Loading
Loading