You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

FileEntryActions.vue 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. <!--
  2. - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
  3. -
  4. - @author John Molakvoæ <skjnldsv@protonmail.com>
  5. -
  6. - @license GNU AGPL version 3 or any later version
  7. -
  8. - This program is free software: you can redistribute it and/or modify
  9. - it under the terms of the GNU Affero General Public License as
  10. - published by the Free Software Foundation, either version 3 of the
  11. - License, or (at your option) any later version.
  12. -
  13. - This program is distributed in the hope that it will be useful,
  14. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. - GNU Affero General Public License for more details.
  17. -
  18. - You should have received a copy of the GNU Affero General Public License
  19. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. -
  21. -->
  22. <template>
  23. <td class="files-list__row-actions"
  24. data-cy-files-list-row-actions>
  25. <!-- Render actions -->
  26. <CustomElementRender v-for="action in enabledRenderActions"
  27. :key="action.id"
  28. :class="'files-list__row-action-' + action.id"
  29. :current-view="currentView"
  30. :render="action.renderInline"
  31. :source="source"
  32. class="files-list__row-action--inline" />
  33. <!-- Menu actions -->
  34. <NcActions ref="actionsMenu"
  35. :boundaries-element="getBoundariesElement"
  36. :container="getBoundariesElement"
  37. :force-name="true"
  38. type="tertiary"
  39. :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
  40. :inline="enabledInlineActions.length"
  41. :open.sync="openedMenu"
  42. @close="openedSubmenu = null">
  43. <!-- Default actions list-->
  44. <NcActionButton v-for="action in enabledMenuActions"
  45. :key="action.id"
  46. :ref="`action-${action.id}`"
  47. :class="{
  48. [`files-list__row-action-${action.id}`]: true,
  49. [`files-list__row-action--menu`]: isMenu(action.id)
  50. }"
  51. :close-after-click="!isMenu(action.id)"
  52. :data-cy-files-list-row-action="action.id"
  53. :is-menu="isMenu(action.id)"
  54. :title="action.title?.([source], currentView)"
  55. @click="onActionClick(action)">
  56. <template #icon>
  57. <NcLoadingIcon v-if="loading === action.id" :size="18" />
  58. <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
  59. </template>
  60. {{ mountType === 'shared' && action.id === 'sharing-status' ? '' : actionDisplayName(action) }}
  61. </NcActionButton>
  62. <!-- Submenu actions list-->
  63. <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
  64. <!-- Back to top-level button -->
  65. <NcActionButton class="files-list__row-action-back" @click="onBackToMenuClick(openedSubmenu)">
  66. <template #icon>
  67. <ArrowLeftIcon />
  68. </template>
  69. {{ actionDisplayName(openedSubmenu) }}
  70. </NcActionButton>
  71. <NcActionSeparator />
  72. <!-- Submenu actions -->
  73. <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
  74. :key="action.id"
  75. :class="`files-list__row-action-${action.id}`"
  76. class="files-list__row-action--submenu"
  77. close-after-click
  78. :data-cy-files-list-row-action="action.id"
  79. :title="action.title?.([source], currentView)"
  80. @click="onActionClick(action)">
  81. <template #icon>
  82. <NcLoadingIcon v-if="loading === action.id" :size="18" />
  83. <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
  84. </template>
  85. {{ actionDisplayName(action) }}
  86. </NcActionButton>
  87. </template>
  88. </NcActions>
  89. </td>
  90. </template>
  91. <script lang="ts">
  92. import type { PropType } from 'vue'
  93. import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files'
  94. import { showError, showSuccess } from '@nextcloud/dialogs'
  95. import { translate as t } from '@nextcloud/l10n'
  96. import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
  97. import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
  98. import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
  99. import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
  100. import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
  101. import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
  102. import Vue, { defineComponent } from 'vue'
  103. import CustomElementRender from '../CustomElementRender.vue'
  104. import logger from '../../logger.js'
  105. // The registered actions list
  106. const actions = getFileActions()
  107. export default defineComponent({
  108. name: 'FileEntryActions',
  109. components: {
  110. ArrowLeftIcon,
  111. CustomElementRender,
  112. NcActionButton,
  113. NcActions,
  114. NcActionSeparator,
  115. NcIconSvgWrapper,
  116. NcLoadingIcon,
  117. },
  118. props: {
  119. filesListWidth: {
  120. type: Number,
  121. required: true,
  122. },
  123. loading: {
  124. type: String,
  125. required: true,
  126. },
  127. opened: {
  128. type: Boolean,
  129. default: false,
  130. },
  131. source: {
  132. type: Object as PropType<Node>,
  133. required: true,
  134. },
  135. gridMode: {
  136. type: Boolean,
  137. default: false,
  138. },
  139. },
  140. data() {
  141. return {
  142. openedSubmenu: null as FileAction | null,
  143. }
  144. },
  145. computed: {
  146. currentDir() {
  147. // Remove any trailing slash but leave root slash
  148. return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
  149. },
  150. currentView(): View {
  151. return this.$navigation.active as View
  152. },
  153. isLoading() {
  154. return this.source.status === NodeStatus.LOADING
  155. },
  156. // Sorted actions that are enabled for this node
  157. enabledActions() {
  158. if (this.source.attributes.failed) {
  159. return []
  160. }
  161. return actions
  162. .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
  163. .sort((a, b) => (a.order || 0) - (b.order || 0))
  164. },
  165. // Enabled action that are displayed inline
  166. enabledInlineActions() {
  167. if (this.filesListWidth < 768 || this.gridMode) {
  168. return []
  169. }
  170. return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
  171. },
  172. // Enabled action that are displayed inline with a custom render function
  173. enabledRenderActions() {
  174. if (this.gridMode) {
  175. return []
  176. }
  177. return this.enabledActions.filter(action => typeof action.renderInline === 'function')
  178. },
  179. // Default actions
  180. enabledDefaultActions() {
  181. return this.enabledActions.filter(action => !!action?.default)
  182. },
  183. // Actions shown in the menu
  184. enabledMenuActions() {
  185. // If we're in a submenu, only render the inline
  186. // actions before the filtered submenu
  187. if (this.openedSubmenu) {
  188. return this.enabledInlineActions
  189. }
  190. const actions = [
  191. // Showing inline first for the NcActions inline prop
  192. ...this.enabledInlineActions,
  193. // Then the rest
  194. ...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
  195. ].filter((value, index, self) => {
  196. // Then we filter duplicates to prevent inline actions to be shown twice
  197. return index === self.findIndex(action => action.id === value.id)
  198. })
  199. // Generate list of all top-level actions ids
  200. const topActionsIds = actions.filter(action => !action.parent).map(action => action.id) as string[]
  201. // Filter actions that are not top-level AND have a valid parent
  202. return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
  203. },
  204. enabledSubmenuActions() {
  205. return this.enabledActions
  206. .filter(action => action.parent)
  207. .reduce((arr, action) => {
  208. if (!arr[action.parent]) {
  209. arr[action.parent] = []
  210. }
  211. arr[action.parent].push(action)
  212. return arr
  213. }, {} as Record<string, FileAction>)
  214. },
  215. openedMenu: {
  216. get() {
  217. return this.opened
  218. },
  219. set(value) {
  220. this.$emit('update:opened', value)
  221. },
  222. },
  223. /**
  224. * Making this a function in case the files-list
  225. * reference changes in the future. That way we're
  226. * sure there is one at the time we call it.
  227. */
  228. getBoundariesElement() {
  229. return document.querySelector('.app-content > .files-list')
  230. },
  231. mountType() {
  232. return this.source._attributes['mount-type']
  233. },
  234. },
  235. methods: {
  236. actionDisplayName(action: FileAction) {
  237. if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') {
  238. // if an inline action is rendered in the menu for
  239. // lack of space we use the title first if defined
  240. const title = action.title([this.source], this.currentView)
  241. if (title) return title
  242. }
  243. return action.displayName([this.source], this.currentView)
  244. },
  245. async onActionClick(action, isSubmenu = false) {
  246. // Skip click on loading
  247. if (this.isLoading || this.loading !== '') {
  248. return
  249. }
  250. // If the action is a submenu, we open it
  251. if (this.enabledSubmenuActions[action.id]) {
  252. this.openedSubmenu = action
  253. return
  254. }
  255. const displayName = action.displayName([this.source], this.currentView)
  256. try {
  257. // Set the loading marker
  258. this.$emit('update:loading', action.id)
  259. Vue.set(this.source, 'status', NodeStatus.LOADING)
  260. const success = await action.exec(this.source, this.currentView, this.currentDir)
  261. // If the action returns null, we stay silent
  262. if (success === null || success === undefined) {
  263. return
  264. }
  265. if (success) {
  266. showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
  267. return
  268. }
  269. showError(t('files', '"{displayName}" action failed', { displayName }))
  270. } catch (e) {
  271. logger.error('Error while executing action', { action, e })
  272. showError(t('files', '"{displayName}" action failed', { displayName }))
  273. } finally {
  274. // Reset the loading marker
  275. this.$emit('update:loading', '')
  276. Vue.set(this.source, 'status', undefined)
  277. // If that was a submenu, we just go back after the action
  278. if (isSubmenu) {
  279. this.openedSubmenu = null
  280. }
  281. }
  282. },
  283. execDefaultAction(event) {
  284. if (this.enabledDefaultActions.length > 0) {
  285. event.preventDefault()
  286. event.stopPropagation()
  287. // Execute the first default action if any
  288. this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
  289. }
  290. },
  291. isMenu(id: string) {
  292. return this.enabledSubmenuActions[id]?.length > 0
  293. },
  294. async onBackToMenuClick(action: FileAction) {
  295. this.openedSubmenu = null
  296. // Wait for first render
  297. await this.$nextTick()
  298. // Focus the previous menu action button
  299. this.$nextTick(() => {
  300. // Focus the action button
  301. const menuAction = this.$refs[`action-${action.id}`]?.[0]
  302. if (menuAction) {
  303. menuAction.$el.querySelector('button')?.focus()
  304. }
  305. })
  306. },
  307. t,
  308. },
  309. })
  310. </script>
  311. <style lang="scss">
  312. // Allow right click to define the position of the menu
  313. // only if defined
  314. main.app-content[style*="mouse-pos-x"] .v-popper__popper {
  315. transform: translate3d(var(--mouse-pos-x), var(--mouse-pos-y), 0px) !important;
  316. // If the menu is too close to the bottom, we move it up
  317. &[data-popper-placement="top"] {
  318. // 34px added to align with the top of the cursor
  319. transform: translate3d(var(--mouse-pos-x), calc(var(--mouse-pos-y) - 50vh + 34px), 0px) !important;
  320. }
  321. // Hide arrow if floating
  322. .v-popper__arrow-container {
  323. display: none;
  324. }
  325. }
  326. </style>
  327. <style lang="scss" scoped>
  328. :deep(.button-vue--icon-and-text, .files-list__row-action-sharing-status) {
  329. .button-vue__text {
  330. color: var(--color-primary-element);
  331. }
  332. .button-vue__icon {
  333. color: var(--color-primary-element);
  334. }
  335. }
  336. </style>