diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-10-13 11:30:34 +0200 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-10-17 11:19:02 +0200 |
commit | 694fd51cbaa18acbaa76a100010f00b904f96f7b (patch) | |
tree | 85c411af182c505bd145746b32cd405d86e843ab | |
parent | 64c32f714894d2c884c82922a5349bbe64a55be8 (diff) | |
download | nextcloud-server-694fd51cbaa18acbaa76a100010f00b904f96f7b.tar.gz nextcloud-server-694fd51cbaa18acbaa76a100010f00b904f96f7b.zip |
fix(files): split FileEntry Name
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
4 files changed, 352 insertions, 254 deletions
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 7ff6186a6e3..40a271aa972 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -40,8 +40,8 @@ <FileEntryCheckbox v-if="visible" :display-name="displayName" :fileid="fileid" - :loading="isLoading" - :nodes="nodes"/> + :is-loading="isLoading" + :nodes="nodes" /> <!-- Link to file --> <td class="files-list__row-name" data-cy-files-list-row-name> @@ -51,38 +51,13 @@ :dragover="dragover" @click.native="execDefaultAction" /> - <!-- Rename input --> - <form v-if="isRenaming" - v-on-click-outside="stopRenaming" - :aria-hidden="!isRenaming" - :aria-label="t('files', 'Rename file')" - class="files-list__row-rename" - @submit.prevent.stop="onRename"> - <NcTextField ref="renameInput" - :label="renameLabel" - :autofocus="true" - :minlength="1" - :required="true" - :value.sync="newName" - enterkeyhint="done" - @keyup="checkInputValidity" - @keyup.esc="stopRenaming" /> - </form> - - <a v-else - ref="basename" - :aria-hidden="isRenaming" - class="files-list__row-name-link" - data-cy-files-list-row-name-link - v-bind="linkTo" - @click="execDefaultAction"> - <!-- File name --> - <span class="files-list__row-name-text"> - <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues--> - <span class="files-list__row-name-" v-text="displayName" /> - <span class="files-list__row-name-ext" v-text="extension" /> - </span> - </a> + <FileEntryName ref="name" + :display-name="displayName" + :extension="extension" + :files-list-width="filesListWidth" + :nodes="nodes" + :source="source" + @click="execDefaultAction" /> </td> <!-- Actions --> @@ -131,20 +106,15 @@ <script lang="ts"> import type { PropType } from 'vue' -import { emit } from '@nextcloud/event-bus' import { extname, join } from 'path' import { FileType, formatFileSize, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' import { getUploader } from '@nextcloud/upload' -import { loadState } from '@nextcloud/initial-state' -import { showError, showSuccess } from '@nextcloud/dialogs' +import { showError } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' import { vOnClickOutside } from '@vueuse/components' -import axios from '@nextcloud/axios' import moment from '@nextcloud/moment' import Vue from 'vue' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' - import { action as sidebarAction } from '../actions/sidebarAction.ts' import { getDragAndDropPreview } from '../utils/dragUtils.ts' import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts' @@ -158,22 +128,21 @@ import { useSelectionStore } from '../store/selection.ts' import CustomElementRender from './CustomElementRender.vue' import FileEntryActions from './FileEntry/FileEntryActions.vue' import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue' +import FileEntryName from './FileEntry/FileEntryName.vue' import FileEntryPreview from './FileEntry/FileEntryPreview.vue' import logger from '../logger.js' Vue.directive('onClickOutside', vOnClickOutside) -const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string - export default Vue.extend({ name: 'FileEntry', components: { CustomElementRender, FileEntryActions, - FileEntryPreview, - NcTextField, FileEntryCheckbox, + FileEntryName, + FileEntryPreview, }, props: { @@ -222,8 +191,6 @@ export default Vue.extend({ return { loading: '', dragover: false, - - NodeStatus, } }, @@ -322,37 +289,6 @@ export default Vue.extend({ return '' }, - linkTo() { - if (this.source.attributes.failed) { - return { - title: t('files', 'This node is unavailable'), - is: 'span', - } - } - - const enabledDefaultActions = this.$refs?.actions?.enabledDefaultActions - if (enabledDefaultActions?.length > 0) { - const action = enabledDefaultActions[0] - const displayName = action.displayName([this.source], this.currentView) - return { - title: displayName, - role: 'button', - } - } - - if (this.source?.permissions & Permission.READ) { - return { - download: this.source.basename, - href: this.source.source, - title: t('files', 'Download file {name}', { name: this.displayName }), - } - } - - return { - is: 'span', - } - }, - draggingFiles() { return this.draggingStore.dragging }, @@ -363,28 +299,12 @@ export default Vue.extend({ return this.selectedFiles.includes(this.fileid) }, - renameLabel() { - const matchLabel: Record<FileType, string> = { - [FileType.File]: t('files', 'File name'), - [FileType.Folder]: t('files', 'Folder name'), - } - return matchLabel[this.source.type] - }, - isRenaming() { return this.renamingStore.renamingNode === this.source }, isRenamingSmallScreen() { return this.isRenaming && this.filesListWidth < 512 }, - newName: { - get() { - return this.renamingStore.newName - }, - set(newName) { - this.renamingStore.newName = newName - }, - }, isActive() { return this.fileid === this.currentFileId?.toString?.() @@ -434,17 +354,6 @@ export default Vue.extend({ source() { this.resetState() }, - - /** - * If renaming starts, select the file name - * in the input, without the extension. - * @param renaming - */ - isRenaming(renaming) { - if (renaming) { - this.startRenaming() - } - }, }, beforeDestroy() { @@ -462,149 +371,6 @@ export default Vue.extend({ this.openedMenu = false }, - /** - * Check if the file name is valid and update the - * input validity using browser's native validation. - * @param event the keyup event - */ - checkInputValidity(event?: KeyboardEvent) { - const input = event.target as HTMLInputElement - const newName = this.newName.trim?.() || '' - logger.debug('Checking input validity', { newName }) - try { - this.isFileNameValid(newName) - input.setCustomValidity('') - input.title = '' - } catch (e) { - input.setCustomValidity(e.message) - input.title = e.message - } finally { - input.reportValidity() - } - }, - isFileNameValid(name) { - const trimmedName = name.trim() - if (trimmedName === '.' || trimmedName === '..') { - throw new Error(t('files', '"{name}" is an invalid file name.', { name })) - } else if (trimmedName.length === 0) { - throw new Error(t('files', 'File name cannot be empty.')) - } else if (trimmedName.indexOf('/') !== -1) { - throw new Error(t('files', '"/" is not allowed inside a file name.')) - } else if (trimmedName.match(OC.config.blacklist_files_regex)) { - throw new Error(t('files', '"{name}" is not an allowed filetype.', { name })) - } else if (this.checkIfNodeExists(name)) { - throw new Error(t('files', '{newName} already exists.', { newName: name })) - } - - const toCheck = trimmedName.split('') - toCheck.forEach(char => { - if (forbiddenCharacters.indexOf(char) !== -1) { - throw new Error(this.t('files', '"{char}" is not allowed inside a file name.', { char })) - } - }) - - return true - }, - checkIfNodeExists(name) { - return this.nodes.find(node => node.basename === name && node !== this.source) - }, - - startRenaming() { - this.$nextTick(() => { - // Using split to get the true string length - const extLength = (this.source.extension || '').split('').length - const length = this.source.basename.split('').length - extLength - const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input - if (!input) { - logger.error('Could not find the rename input') - return - } - input.setSelectionRange(0, length) - input.focus() - - // Trigger a keyup event to update the input validity - input.dispatchEvent(new Event('keyup')) - }) - }, - stopRenaming() { - if (!this.isRenaming) { - return - } - - // Reset the renaming store - this.renamingStore.$reset() - }, - - // Rename and move the file - async onRename() { - const oldName = this.source.basename - const oldEncodedSource = this.source.encodedSource - const newName = this.newName.trim?.() || '' - if (newName === '') { - showError(t('files', 'Name cannot be empty')) - return - } - - if (oldName === newName) { - this.stopRenaming() - return - } - - // Checking if already exists - if (this.checkIfNodeExists(newName)) { - showError(t('files', 'Another entry with the same name already exists')) - return - } - - // Set loading state - this.loading = 'renaming' - Vue.set(this.source, 'status', NodeStatus.LOADING) - - // Update node - this.source.rename(newName) - - logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource }) - try { - await axios({ - method: 'MOVE', - url: oldEncodedSource, - headers: { - Destination: this.source.encodedSource, - }, - }) - - // Success 🎉 - emit('files:node:updated', this.source) - emit('files:node:renamed', this.source) - showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName })) - - // Reset the renaming store - this.stopRenaming() - this.$nextTick(() => { - this.$refs.basename.focus() - }) - } catch (error) { - logger.error('Error while renaming file', { error }) - this.source.rename(oldName) - this.$refs.renameInput.focus() - - // TODO: 409 means current folder does not exist, redirect ? - if (error?.response?.status === 404) { - showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName })) - return - } else if (error?.response?.status === 412) { - showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir })) - return - } - - // Unknown error - showError(t('files', 'Could not rename "{oldName}"', { oldName })) - } finally { - this.loading = false - Vue.set(this.source, 'status', undefined) - } - }, - // Open the actions menu on right click onRightClick(event) { // If already opened, fallback to default browser @@ -621,8 +387,8 @@ export default Vue.extend({ event.stopPropagation() }, - execDefaultAction() { - this.$refs.actions.execDefaultAction() + execDefaultAction(...args) { + this.$refs.actions.execDefaultAction(...args) }, openDetailsIfAvailable(event) { diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index 84d8f4a40f9..e8af5c0fe16 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -36,7 +36,7 @@ ref="actionsMenu" :boundaries-element="getBoundariesElement()" :container="getBoundariesElement()" - :disabled="isLoading" + :disabled="isLoading || loading !== ''" :force-name="true" :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" :inline="enabledInlineActions.length" @@ -59,7 +59,7 @@ </template> <script lang="ts"> -import { DefaultType, FileAction, Folder, Node, NodeStatus, View, getFileActions } from '@nextcloud/files' +import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n'; import Vue, { PropType } from 'vue' @@ -113,6 +113,10 @@ export default Vue.extend({ }, computed: { + currentDir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') + }, currentView(): View { return this.$navigation.active as View }, diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue index 376d14d4073..961e4bf2266 100644 --- a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue +++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue @@ -21,8 +21,9 @@ --> <template> <td class="files-list__row-checkbox"> - <NcLoadingIcon v-if="loading" /> - <NcCheckboxRadioSwitch :aria-label="t('files', 'Select the row for {displayName}', { displayName })" + <NcLoadingIcon v-if="isLoading" /> + <NcCheckboxRadioSwitch v-else + :aria-label="t('files', 'Select the row for {displayName}', { displayName })" :checked="isSelected" @update:checked="onSelectionChange" /> </td> @@ -57,7 +58,7 @@ export default Vue.extend({ type: String, required: true, }, - loading: { + isLoading: { type: Boolean, default: false, }, diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue new file mode 100644 index 00000000000..d70eccec8a0 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -0,0 +1,327 @@ +<!-- + - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<template> + <!-- Rename input --> + <form v-if="isRenaming" + v-on-click-outside="stopRenaming" + :aria-hidden="!isRenaming" + :aria-label="t('files', 'Rename file')" + class="files-list__row-rename" + @submit.prevent.stop="onRename"> + <NcTextField ref="renameInput" + :label="renameLabel" + :autofocus="true" + :minlength="1" + :required="true" + :value.sync="newName" + enterkeyhint="done" + @keyup="checkInputValidity" + @keyup.esc="stopRenaming" /> + </form> + + <a v-else + ref="basename" + :aria-hidden="isRenaming" + class="files-list__row-name-link" + data-cy-files-list-row-name-link + v-bind="linkTo" + @click="$emit('click', $event)"> + <!-- File name --> + <span class="files-list__row-name-text"> + <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues--> + <span class="files-list__row-name-" v-text="displayName" /> + <span class="files-list__row-name-ext" v-text="extension" /> + </span> + </a> +</template> + +<script lang="ts"> +import { emit } from '@nextcloud/event-bus' +import { FileType, NodeStatus, Permission } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import Vue, { PropType } from 'vue' + +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' + +import { useRenamingStore } from '../../store/renaming.ts' +import logger from '../../logger.js' + +const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string + +export default Vue.extend({ + name: 'FileEntryName', + + components: { + NcTextField, + }, + + props: { + displayName: { + type: String, + required: true, + }, + extension: { + type: String, + required: true, + }, + filesListWidth: { + type: Number, + required: true, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + }, + + setup() { + const renamingStore = useRenamingStore() + return { + renamingStore, + } + }, + + computed: { + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + newName: { + get() { + return this.renamingStore.newName + }, + set(newName) { + this.renamingStore.newName = newName + }, + }, + + renameLabel() { + const matchLabel: Record<FileType, string> = { + [FileType.File]: t('files', 'File name'), + [FileType.Folder]: t('files', 'Folder name'), + } + return matchLabel[this.source.type] + }, + + linkTo() { + if (this.source.attributes.failed) { + return { + title: t('files', 'This node is unavailable'), + is: 'span', + } + } + + const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions + if (enabledDefaultActions?.length > 0) { + const action = enabledDefaultActions[0] + const displayName = action.displayName([this.source], this.currentView) + return { + title: displayName, + role: 'button', + } + } + + if (this.source?.permissions & Permission.READ) { + return { + download: this.source.basename, + href: this.source.source, + title: t('files', 'Download file {name}', { name: this.displayName }), + } + } + + return { + is: 'span', + } + }, + }, + + watch: { + /** + * If renaming starts, select the file name + * in the input, without the extension. + * @param renaming + */ + isRenaming(renaming: boolean) { + if (renaming) { + this.startRenaming() + } + }, + }, + + methods: { + /** + * Check if the file name is valid and update the + * input validity using browser's native validation. + * @param event the keyup event + */ + checkInputValidity(event?: KeyboardEvent) { + const input = event.target as HTMLInputElement + const newName = this.newName.trim?.() || '' + logger.debug('Checking input validity', { newName }) + try { + this.isFileNameValid(newName) + input.setCustomValidity('') + input.title = '' + } catch (e) { + input.setCustomValidity(e.message) + input.title = e.message + } finally { + input.reportValidity() + } + }, + isFileNameValid(name) { + const trimmedName = name.trim() + if (trimmedName === '.' || trimmedName === '..') { + throw new Error(t('files', '"{name}" is an invalid file name.', { name })) + } else if (trimmedName.length === 0) { + throw new Error(t('files', 'File name cannot be empty.')) + } else if (trimmedName.indexOf('/') !== -1) { + throw new Error(t('files', '"/" is not allowed inside a file name.')) + } else if (trimmedName.match(OC.config.blacklist_files_regex)) { + throw new Error(t('files', '"{name}" is not an allowed filetype.', { name })) + } else if (this.checkIfNodeExists(name)) { + throw new Error(t('files', '{newName} already exists.', { newName: name })) + } + + const toCheck = trimmedName.split('') + toCheck.forEach(char => { + if (forbiddenCharacters.indexOf(char) !== -1) { + throw new Error(this.t('files', '"{char}" is not allowed inside a file name.', { char })) + } + }) + + return true + }, + checkIfNodeExists(name) { + return this.nodes.find(node => node.basename === name && node !== this.source) + }, + + startRenaming() { + this.$nextTick(() => { + // Using split to get the true string length + const extLength = (this.source.extension || '').split('').length + const length = this.source.basename.split('').length - extLength + const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input + if (!input) { + logger.error('Could not find the rename input') + return + } + input.setSelectionRange(0, length) + input.focus() + + // Trigger a keyup event to update the input validity + input.dispatchEvent(new Event('keyup')) + }) + }, + stopRenaming() { + if (!this.isRenaming) { + return + } + + // Reset the renaming store + this.renamingStore.$reset() + }, + + // Rename and move the file + async onRename() { + const oldName = this.source.basename + const oldEncodedSource = this.source.encodedSource + const newName = this.newName.trim?.() || '' + if (newName === '') { + showError(t('files', 'Name cannot be empty')) + return + } + + if (oldName === newName) { + this.stopRenaming() + return + } + + // Checking if already exists + if (this.checkIfNodeExists(newName)) { + showError(t('files', 'Another entry with the same name already exists')) + return + } + + // Set loading state + this.loading = 'renaming' + Vue.set(this.source, 'status', NodeStatus.LOADING) + + // Update node + this.source.rename(newName) + + logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource }) + try { + await axios({ + method: 'MOVE', + url: oldEncodedSource, + headers: { + Destination: this.source.encodedSource, + }, + }) + + // Success 🎉 + emit('files:node:updated', this.source) + emit('files:node:renamed', this.source) + showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName })) + + // Reset the renaming store + this.stopRenaming() + this.$nextTick(() => { + this.$refs.basename.focus() + }) + } catch (error) { + logger.error('Error while renaming file', { error }) + this.source.rename(oldName) + this.$refs.renameInput.focus() + + // TODO: 409 means current folder does not exist, redirect ? + if (error?.response?.status === 404) { + showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName })) + return + } else if (error?.response?.status === 412) { + showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir })) + return + } + + // Unknown error + showError(t('files', 'Could not rename "{oldName}"', { oldName })) + } finally { + this.loading = false + Vue.set(this.source, 'status', undefined) + } + }, + + + t, + }, +}) +</script> |