FileEntryActions.vue 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  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. :disabled="isLoading || loading !== ''"
  38. :force-name="true"
  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. :class="{
  47. [`files-list__row-action-${action.id}`]: true,
  48. [`files-list__row-action--menu`]: isMenu(action.id)
  49. }"
  50. :close-after-click="!isMenu(action.id)"
  51. :data-cy-files-list-row-action="action.id"
  52. :is-menu="isMenu(action.id)"
  53. :title="action.title?.([source], currentView)"
  54. @click="onActionClick(action)">
  55. <template #icon>
  56. <NcLoadingIcon v-if="loading === action.id" :size="18" />
  57. <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
  58. </template>
  59. {{ mountType === 'shared' && action.id === 'sharing-status' ? '' : actionDisplayName(action) }}
  60. </NcActionButton>
  61. <!-- Submenu actions list-->
  62. <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
  63. <!-- Back to top-level button -->
  64. <NcActionButton class="files-list__row-action-back" @click="openedSubmenu = null">
  65. <template #icon>
  66. <ArrowLeftIcon />
  67. </template>
  68. {{ actionDisplayName(openedSubmenu) }}
  69. </NcActionButton>
  70. <NcActionSeparator />
  71. <!-- Submenu actions -->
  72. <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
  73. :key="action.id"
  74. :class="`files-list__row-action-${action.id}`"
  75. class="files-list__row-action--submenu"
  76. :close-after-click="false /* never close submenu, just go back */"
  77. :data-cy-files-list-row-action="action.id"
  78. :title="action.title?.([source], currentView)"
  79. @click="onActionClick(action)">
  80. <template #icon>
  81. <NcLoadingIcon v-if="loading === action.id" :size="18" />
  82. <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
  83. </template>
  84. {{ actionDisplayName(action) }}
  85. </NcActionButton>
  86. </template>
  87. </NcActions>
  88. </td>
  89. </template>
  90. <script lang="ts">
  91. import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files'
  92. import { showError, showSuccess } from '@nextcloud/dialogs'
  93. import { translate as t } from '@nextcloud/l10n';
  94. import Vue, { PropType } from 'vue'
  95. import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
  96. import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue'
  97. import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
  98. import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
  99. import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
  100. import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
  101. import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
  102. import CustomElementRender from '../CustomElementRender.vue'
  103. import logger from '../../logger.js'
  104. // The registered actions list
  105. const actions = getFileActions()
  106. export default Vue.extend({
  107. name: 'FileEntryActions',
  108. components: {
  109. ArrowLeftIcon,
  110. ChevronRightIcon,
  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. // If the action is a submenu, we open it
  247. if (this.enabledSubmenuActions[action.id]) {
  248. this.openedSubmenu = action
  249. return
  250. }
  251. const displayName = action.displayName([this.source], this.currentView)
  252. try {
  253. // Set the loading marker
  254. this.$emit('update:loading', action.id)
  255. Vue.set(this.source, 'status', NodeStatus.LOADING)
  256. const success = await action.exec(this.source, this.currentView, this.currentDir)
  257. // If the action returns null, we stay silent
  258. if (success === null || success === undefined) {
  259. return
  260. }
  261. if (success) {
  262. showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
  263. return
  264. }
  265. showError(t('files', '"{displayName}" action failed', { displayName }))
  266. } catch (e) {
  267. logger.error('Error while executing action', { action, e })
  268. showError(t('files', '"{displayName}" action failed', { displayName }))
  269. } finally {
  270. // Reset the loading marker
  271. this.$emit('update:loading', '')
  272. Vue.set(this.source, 'status', undefined)
  273. // If that was a submenu, we just go back after the action
  274. if (isSubmenu) {
  275. this.openedSubmenu = null
  276. }
  277. }
  278. },
  279. execDefaultAction(event) {
  280. if (this.enabledDefaultActions.length > 0) {
  281. event.preventDefault()
  282. event.stopPropagation()
  283. // Execute the first default action if any
  284. this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
  285. }
  286. },
  287. isMenu(id: string) {
  288. return this.enabledSubmenuActions[id]?.length > 0
  289. },
  290. t,
  291. },
  292. })
  293. </script>