nextcloud/apps/files/src/components/NewNodeDialog.vue
Ferdinand Thiessen 24b284e773
fix(files): distinguish between files and folders for validation messages
Unit tests are AI assisted.

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-07-08 19:25:44 +02:00

176 lines
4.3 KiB
Vue

<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog data-cy-files-new-node-dialog
:name="name"
:open="open"
close-on-click-outside
out-transition
@update:open="emit('close', null)">
<template #actions>
<NcButton data-cy-files-new-node-dialog-submit
type="primary"
:disabled="validity !== ''"
@click="submit">
{{ t('files', 'Create') }}
</NcButton>
</template>
<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" />
<!-- Hidden file warning -->
<NcNoteCard v-if="isHiddenFileName"
type="warning"
:text="t('files', 'Files starting with a dot are hidden by default')" />
</form>
</NcDialog>
</template>
<script setup lang="ts">
import type { ComponentPublicInstance, PropType } from 'vue'
import { getUniqueName } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { extname } from 'path'
import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue'
import { getFilenameValidity } from '../utils/filenameValidity.ts'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
const props = defineProps({
/**
* 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'),
},
/**
* Whether the name is for a folder, which affects the validation of the name. Defaults to false.
*/
isFolder: {
type: Boolean,
default: false,
},
})
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('')
const isHiddenFileName = computed(() => {
// Check if the name starts with a dot, which indicates a hidden file
return localDefaultName.value.trim().startsWith('.')
})
/**
* Focus the filename input field
*/
function focusInput() {
nextTick(() => {
// get the input element
const input = nameInput.value?.$el.querySelector('input')
if (!props.open || !input) {
return
}
// 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)
})
}
/**
* Trigger submit on the form
*/
function submit() {
formElement.value?.requestSubmit()
}
// Reset local name on props change
watch(() => [props.defaultName, props.otherNames], () => {
localDefaultName.value = getUniqueName(props.defaultName, props.otherNames).trim()
})
// Validate the local name
watchEffect(() => {
if (props.otherNames.includes(localDefaultName.value.trim())) {
validity.value = t('files', 'This name is already in use.')
} else {
validity.value = getFilenameValidity(localDefaultName.value.trim(), false, props.isFolder)
}
const input = nameInput.value?.$el.querySelector('input')
if (input) {
input.setCustomValidity(validity.value)
input.reportValidity()
}
})
// Ensure the input is focussed even if the dialog is already mounted but not open
watch(() => props.open, () => {
nextTick(() => {
focusInput()
})
})
onMounted(() => {
// on mounted lets use the unique name
localDefaultName.value = getUniqueName(localDefaultName.value, props.otherNames).trim()
nextTick(() => focusInput())
})
</script>
<style scoped>
.new-node-dialog__form {
/* Ensure the dialog does not jump when there is a validity error */
min-height: calc(2 * var(--default-clickable-area));
}
</style>