diff options
Diffstat (limited to 'apps/files/src/components/FileEntry/FileEntryName.vue')
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryName.vue | 252 |
1 files changed, 101 insertions, 151 deletions
diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue index 4e5a3571e74..418f9581eb6 100644 --- a/apps/files/src/components/FileEntry/FileEntryName.vue +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -5,6 +5,7 @@ <template> <!-- Rename input --> <form v-if="isRenaming" + ref="renameForm" v-on-click-outside="onRename" :aria-label="t('files', 'Rename file')" class="files-list__row-rename" @@ -16,45 +17,42 @@ :required="true" :value.sync="newName" enterkeyhint="done" - @keyup="checkInputValidity" @keyup.esc="stopRenaming" /> </form> <component :is="linkTo.is" v-else ref="basename" - :aria-hidden="isRenaming" class="files-list__row-name-link" data-cy-files-list-row-name-link v-bind="linkTo.params"> - <!-- 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" /> + <!-- Filename --> + <span class="files-list__row-name-text" dir="auto"> + <!-- Keep the filename stuck to the extension to avoid whitespace rendering issues--> + <span class="files-list__row-name-" v-text="basename" /> + <span v-if="userConfigStore.userConfig.show_files_extensions" class="files-list__row-name-ext" v-text="extension" /> </span> </component> </template> <script lang="ts"> -import type { Node } from '@nextcloud/files' +import type { FileAction, Node } from '@nextcloud/files' import type { PropType } from 'vue' import { showError, showSuccess } from '@nextcloud/dialogs' -import { emit } from '@nextcloud/event-bus' -import { FileType, NodeStatus, Permission } from '@nextcloud/files' -import { loadState } from '@nextcloud/initial-state' +import { FileType, NodeStatus } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' -import axios, { isAxiosError } from '@nextcloud/axios' -import { defineComponent } from 'vue' +import { defineComponent, inject } from 'vue' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcTextField from '@nextcloud/vue/components/NcTextField' -import { useNavigation } from '../../composables/useNavigation' +import { getFilenameValidity } from '../../utils/filenameValidity.ts' +import { useFileListWidth } from '../../composables/useFileListWidth.ts' +import { useNavigation } from '../../composables/useNavigation.ts' import { useRenamingStore } from '../../store/renaming.ts' -import logger from '../../logger.js' - -const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', []) +import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import { useUserConfigStore } from '../../store/userconfig.ts' +import logger from '../../logger.ts' export default defineComponent({ name: 'FileEntryName', @@ -64,18 +62,20 @@ export default defineComponent({ }, props: { - displayName: { + /** + * The filename without extension + */ + basename: { type: String, required: true, }, + /** + * The extension of the filename + */ extension: { type: String, required: true, }, - filesListWidth: { - type: Number, - required: true, - }, nodes: { type: Array as PropType<Node[]>, required: true, @@ -91,13 +91,23 @@ export default defineComponent({ }, setup() { - const { currentView } = useNavigation() + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { directory } = useRouteParameters() + const filesListWidth = useFileListWidth() const renamingStore = useRenamingStore() + const userConfigStore = useUserConfigStore() + + const defaultFileAction = inject<FileAction | undefined>('defaultFileAction') return { currentView, + defaultFileAction, + directory, + filesListWidth, renamingStore, + userConfigStore, } }, @@ -109,17 +119,17 @@ export default defineComponent({ return this.isRenaming && this.filesListWidth < 512 }, newName: { - get() { - return this.renamingStore.newName + get(): string { + return this.renamingStore.newNodeName }, - set(newName) { - this.renamingStore.newName = newName + set(newName: string) { + this.renamingStore.newNodeName = newName }, }, renameLabel() { const matchLabel: Record<FileType, string> = { - [FileType.File]: t('files', 'File name'), + [FileType.File]: t('files', 'Filename'), [FileType.Folder]: t('files', 'Folder name'), } return matchLabel[this.source.type] @@ -135,32 +145,20 @@ export default defineComponent({ } } - const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions - if (enabledDefaultActions?.length > 0) { - const action = enabledDefaultActions[0] - const displayName = action.displayName([this.source], this.currentView) + if (this.defaultFileAction) { + const displayName = this.defaultFileAction.displayName([this.source], this.currentView) return { - is: 'a', + is: 'button', params: { + 'aria-label': displayName, title: displayName, - role: 'button', - tabindex: '0', - }, - } - } - - if (this.source?.permissions & Permission.READ) { - return { - is: 'a', - params: { - download: this.source.basename, - href: this.source.source, - title: t('files', 'Download file {name}', { name: this.displayName }), tabindex: '0', }, } } + // nothing interactive here, there is no default action + // so if not even the download action works we only can show the list entry return { is: 'span', } @@ -169,7 +167,7 @@ export default defineComponent({ watch: { /** - * If renaming starts, select the file name + * If renaming starts, select the filename * in the input, without the extension. * @param renaming */ @@ -181,56 +179,30 @@ export default defineComponent({ } }, }, - }, - 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 + newName() { + // Check validity of the new name const newName = this.newName.trim?.() || '' - logger.debug('Checking input validity', { newName }) - try { - this.isFileNameValid(newName) - input.setCustomValidity('') - input.title = '' - } catch (e) { - if (e instanceof Error) { - input.setCustomValidity(e.message) - input.title = e.message - } else { - input.setCustomValidity(t('files', 'Invalid file name')) - } - } finally { - input.reportValidity() - } - }, - - isFileNameValid(name: string) { - 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(window.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 input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') + if (!input) { + return } - const char = forbiddenCharacters.find((char) => trimmedName.includes(char)) - if (char) { - throw new Error(t('files', '"{char}" is not allowed inside a file name.', { char })) + let validity = getFilenameValidity(newName) + // Checking if already exists + if (validity === '' && this.checkIfNodeExists(newName)) { + validity = t('files', 'Another entry with the same name already exists.') } - - return true + this.$nextTick(() => { + if (this.isRenaming) { + input.setCustomValidity(validity) + input.reportValidity() + } + }) }, + }, + methods: { checkIfNodeExists(name: string) { return this.nodes.find(node => node.basename === name && node !== this.source) }, @@ -238,20 +210,20 @@ export default defineComponent({ 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 + const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') if (!input) { logger.error('Could not find the rename input') return } - input.setSelectionRange(0, length) input.focus() + const length = this.source.basename.length - (this.source.extension ?? '').length + input.setSelectionRange(0, length) // Trigger a keyup event to update the input validity input.dispatchEvent(new Event('keyup')) }) }, + stopRenaming() { if (!this.isRenaming) { return @@ -263,72 +235,37 @@ export default defineComponent({ // 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')) + const form = this.$refs.renameForm as HTMLFormElement + if (!form.checkValidity()) { + showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName)) return } - if (oldName === newName) { + const oldName = this.source.basename + if (newName === oldName) { 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.$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, - Overwrite: 'F', - }, - }) - - // 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() - - if (isAxiosError(error)) { - // 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 - } + const status = await this.renamingStore.rename() + if (status) { + showSuccess( + t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }), + ) + this.$nextTick(() => { + const nameContainer = this.$refs.basename as HTMLElement | undefined + nameContainer?.focus() + }) + } else { + // Was cancelled - meaning the renaming state is just reset } - - // Unknown error - showError(t('files', 'Could not rename "{oldName}"', { oldName })) - } finally { - this.$set(this.source, 'status', undefined) + } catch (error) { + logger.error(error as Error) + showError((error as Error).message) + // And ensure we reset to the renaming state + this.startRenaming() } }, @@ -336,3 +273,16 @@ export default defineComponent({ }, }) </script> + +<style scoped lang="scss"> +button.files-list__row-name-link { + background-color: unset; + border: none; + font-weight: normal; + + &:active { + // No active styles - handled by the row entry + background-color: unset !important; + } +} +</style> |