diff options
author | John Molakvoæ <skjnldsv@users.noreply.github.com> | 2024-08-03 08:14:41 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-08-03 08:14:41 +0200 |
commit | b41a8ac0ee3000c2774f19531a47cd9eccfe1849 (patch) | |
tree | ad456001e8c55dba71e0c2ea71aa2c5b7aa1205b /apps | |
parent | 49d6e145a2c456b9a731b82adfe3c0d63ad8c37b (diff) | |
parent | f1d9ab1c95b3e59b99a75a4d17478e572d73cb4c (diff) | |
download | nextcloud-server-b41a8ac0ee3000c2774f19531a47cd9eccfe1849.tar.gz nextcloud-server-b41a8ac0ee3000c2774f19531a47cd9eccfe1849.zip |
Merge pull request #46774 from nextcloud/backport/46767/stable29
Diffstat (limited to 'apps')
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryName.vue | 6 | ||||
-rw-r--r-- | apps/files/src/components/NewNodeDialog.vue | 309 |
2 files changed, 123 insertions, 192 deletions
diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue index d157730a3df..9aa3bcfeb87 100644 --- a/apps/files/src/components/FileEntry/FileEntryName.vue +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -44,7 +44,7 @@ class="files-list__row-name-link" data-cy-files-list-row-name-link v-bind="linkTo.params"> - <!-- File name --> + <!-- Filename --> <span class="files-list__row-name-text"> <!-- Keep the filename stuck to the extension to avoid whitespace rendering issues--> <span class="files-list__row-name-" v-text="basename" /> @@ -140,7 +140,7 @@ export default defineComponent({ 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] @@ -190,7 +190,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 */ diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue index 0f6a739d21c..b4647724de3 100644 --- a/apps/files/src/components/NewNodeDialog.vue +++ b/apps/files/src/components/NewNodeDialog.vue @@ -1,226 +1,157 @@ <!-- - - @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> - - - - @author Ferdinand Thiessen <opensource@fthiessen.de> - - - - @license AGPL-3.0-or-later - - - - 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/>. - - - --> + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <NcDialog :name="name" + <NcDialog data-cy-files-new-node-dialog + :name="name" :open="open" close-on-click-outside out-transition - @update:open="onClose"> + @update:open="emit('close', null)"> <template #actions> - <NcButton type="primary" - :disabled="!isUniqueName" - @click="onCreate"> + <NcButton data-cy-files-new-node-dialog-submit + type="primary" + :disabled="validity !== ''" + @click="submit"> {{ t('files', 'Create') }} </NcButton> </template> - <form @submit.prevent="onCreate"> - <NcTextField ref="input" - class="dialog__input" - :error="!isUniqueName" - :helper-text="errorMessage" + <form ref="formElement" + class="new-node-dialog__form" + @submit.prevent="emit('close', localDefaultName)"> + <NcTextField ref="nameInput" + data-cy-files-new-node-dialog-input + :error="validity !== ''" + :helper-text="validity" :label="label" - :value.sync="localDefaultName" - @keyup="checkInputValidity" /> + :value.sync="localDefaultName" /> </form> </NcDialog> </template> -<script lang="ts"> -import type { PropType } from 'vue' - -import { defineComponent } from 'vue' -import { translate as t } from '@nextcloud/l10n' +<script setup lang="ts"> +import type { ComponentPublicInstance, PropType } from 'vue' import { getUniqueName } from '@nextcloud/files' -import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { extname } from 'path' +import { nextTick, onMounted, ref, watch, watchEffect } from 'vue' +import { getFilenameValidity } from '../utils/filenameValidity.ts' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' -import logger from '../logger.js' - -interface ICanFocus { - focus: () => void -} -const forbiddenCharacters = loadState<string>('files', 'forbiddenCharacters', '').split('') - -export default defineComponent({ - name: 'NewNodeDialog', - components: { - NcButton, - NcDialog, - NcTextField, +const props = defineProps({ + /** + * The name to be used by default + */ + defaultName: { + type: String, + default: t('files', 'New folder'), }, - props: { - /** - * The name to be used by default - */ - defaultName: { - type: String, - default: t('files', 'New folder'), - }, - /** - * Other files that are in the current directory - */ - otherNames: { - type: Array as PropType<string[]>, - default: () => [], - }, - /** - * Open state of the dialog - */ - open: { - type: Boolean, - default: true, - }, - /** - * Dialog name - */ - name: { - type: String, - default: t('files', 'Create new folder'), - }, - /** - * Input label - */ - label: { - type: String, - default: t('files', 'Folder name'), - }, + /** + * Other files that are in the current directory + */ + otherNames: { + type: Array as PropType<string[]>, + default: () => [], }, - emits: { - close: (name: string|null) => name === null || name, + /** + * Open state of the dialog + */ + open: { + type: Boolean, + default: true, }, - data() { - return { - localDefaultName: this.defaultName || t('files', 'New folder'), - } + /** + * Dialog name + */ + name: { + type: String, + default: t('files', 'Create new folder'), }, - computed: { - errorMessage() { - if (this.isUniqueName) { - return '' - } else { - return t('files', 'A file or folder with that name already exists.') - } - }, - uniqueName() { - return getUniqueName(this.localDefaultName, this.otherNames) - }, - isUniqueName() { - return this.localDefaultName === this.uniqueName - }, + /** + * Input label + */ + label: { + type: String, + default: t('files', 'Folder name'), }, - watch: { - defaultName() { - this.localDefaultName = this.defaultName || t('files', 'New folder') - }, +}) - /** - * Ensure the input is focussed even if the dialog is already mounted but not open - */ - open() { - this.$nextTick(() => this.focusInput()) - }, - }, - mounted() { - // on mounted lets use the unique name - this.localDefaultName = this.uniqueName - this.$nextTick(() => this.focusInput()) - }, - methods: { - t, +const emit = defineEmits<{ + (event: 'close', name: string | null): void +}>() + +const localDefaultName = ref<string>(props.defaultName) +const nameInput = ref<ComponentPublicInstance>() +const formElement = ref<HTMLFormElement>() +const validity = ref('') + +/** + * Focus the filename input field + */ +function focusInput() { + nextTick(() => { + // get the input element + const input = nameInput.value?.$el.querySelector('input') + if (!props.open || !input) { + return + } - /** - * Focus the filename input field - */ - focusInput() { - if (this.open) { - this.$nextTick(() => (this.$refs.input as unknown as ICanFocus)?.focus?.()) - } - }, + // length of the basename + const length = localDefaultName.value.length - extname(localDefaultName.value).length + // focus the input + input.focus() + // and set the selection to the basename (name without extension) + input.setSelectionRange(0, length) + }) +} - onCreate() { - this.$emit('close', this.localDefaultName) - }, - onClose(state: boolean) { - if (!state) { - this.$emit('close', null) - } - }, +/** + * Trigger submit on the form + */ +function submit() { + formElement.value?.requestSubmit() +} - /** - * 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.localDefaultName.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() - } - }, +// Reset local name on props change +watch(() => props.defaultName, () => { + localDefaultName.value = getUniqueName(props.defaultName, props.otherNames) +}) - isFileNameValid(name: string) { - const trimmedName = name.trim() - const char = trimmedName.indexOf('/') !== -1 - ? '/' - : forbiddenCharacters.find((char) => trimmedName.includes(char)) +// Validate the local name +watchEffect(() => { + if (props.otherNames.includes(localDefaultName.value)) { + validity.value = t('files', 'This name is already in use.') + } else { + validity.value = getFilenameValidity(localDefaultName.value) + } + const input = nameInput.value?.$el.querySelector('input') + if (input) { + input.setCustomValidity(validity.value) + input.reportValidity() + } +}) - 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 (char) { - throw new Error(t('files', '"{char}" is not allowed inside a file name.', { char })) - } else if (trimmedName.match(window.OC.config.blacklist_files_regex)) { - throw new Error(t('files', '"{name}" is not an allowed filetype.', { name })) - } +// Ensure the input is focussed even if the dialog is already mounted but not open +watch(() => props.open, () => { + nextTick(() => { + focusInput() + }) +}) - return true - }, - }, +onMounted(() => { + // on mounted lets use the unique name + localDefaultName.value = getUniqueName(localDefaultName.value, props.otherNames) + nextTick(() => focusInput()) }) </script> -<style lang="scss" scoped> -.dialog__input { - :deep(input:invalid) { - // Show red border on invalid input - border-color: var(--color-error); - color: red; - } +<style scoped> +.new-node-dialog__form { + /* Ensure the dialog does not jump when there is a validity error */ + min-height: calc(3 * var(--default-clickable-area)); } </style> |