From 11de05bc3a2f854d11d7b9bda59e7752731ca18f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:22:58 -0700 Subject: [PATCH 1/2] fix: show a single duplicate-name error during file creation Fixes #62686 --- .../src/components/NewNodeDialog.spec.ts | 65 +++++++++++++++++++ apps/files/src/components/NewNodeDialog.vue | 1 - 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 apps/files/src/components/NewNodeDialog.spec.ts diff --git a/apps/files/src/components/NewNodeDialog.spec.ts b/apps/files/src/components/NewNodeDialog.spec.ts new file mode 100644 index 0000000000000..77f59a6352f95 --- /dev/null +++ b/apps/files/src/components/NewNodeDialog.spec.ts @@ -0,0 +1,65 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import NewNodeDialog from './NewNodeDialog.vue' + +describe('NewNodeDialog', () => { + afterEach(cleanup) + + it('shows a single inline error for a duplicate name without reporting native validity', async () => { + const component = render(NewNodeDialog, { + props: { + otherNames: ['existing.txt'], + }, + }) + const input = component.getByRole('textbox', { name: 'Folder name' }) as HTMLInputElement + const reportValidity = vi.spyOn(input, 'reportValidity') + const setCustomValidity = vi.spyOn(input, 'setCustomValidity') + + await fireEvent.update(input, 'existing.txt') + + expect(component.getAllByText('This name is already in use.')).toHaveLength(1) + expect(component.getByRole('button', { name: 'Create' })).toBeDisabled() + expect(setCustomValidity).toHaveBeenLastCalledWith('This name is already in use.') + expect(input.validity.valid).toBe(false) + expect(input.validationMessage).toBe('This name is already in use.') + expect(reportValidity).not.toHaveBeenCalled() + }) + + it('clears inline and native validity when the name becomes unique', async () => { + const component = render(NewNodeDialog, { + props: { + otherNames: ['existing.txt'], + }, + }) + const input = component.getByRole('textbox', { name: 'Folder name' }) as HTMLInputElement + + await fireEvent.update(input, 'existing.txt') + expect(input.validationMessage).toBe('This name is already in use.') + + await fireEvent.update(input, 'unique.txt') + + expect(component.queryByText('This name is already in use.')).not.toBeInTheDocument() + expect(input.validity.valid).toBe(true) + expect(input.validationMessage).toBe('') + expect(component.getByRole('button', { name: 'Create' })).toBeEnabled() + }) + + it('shows other filename errors inline without reporting native validity', async () => { + const component = render(NewNodeDialog) + const input = component.getByRole('textbox', { name: 'Folder name' }) as HTMLInputElement + const reportValidity = vi.spyOn(input, 'reportValidity') + + await fireEvent.update(input, '') + + expect(component.getAllByText('Filename must not be empty.')).toHaveLength(1) + expect(component.getByRole('button', { name: 'Create' })).toBeDisabled() + expect(input.validity.valid).toBe(false) + expect(input.validationMessage).toBe('Filename must not be empty.') + expect(reportValidity).not.toHaveBeenCalled() + }) +}) diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue index a0bc4d15ef0c7..8e5380ea96e35 100644 --- a/apps/files/src/components/NewNodeDialog.vue +++ b/apps/files/src/components/NewNodeDialog.vue @@ -147,7 +147,6 @@ watchEffect(() => { const input = nameInput.value?.$el.querySelector('input') if (input) { input.setCustomValidity(validity.value) - input.reportValidity() } }) From 31e5c3defeac4b18e361208222e0fbf12b607246 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 21:52:14 +0000 Subject: [PATCH 2/2] fix(files): use native validation for the new node name Show the name validity through the platform validation popup instead of the NcTextField helper text, so a duplicate name is only reported once. This matches the existing native validation of the rename input and the federated share dialog. Fixes #62686 Co-authored-by: Matt Van Horn --- .../src/components/NewNodeDialog.spec.ts | 45 +++++++++++++------ apps/files/src/components/NewNodeDialog.vue | 5 +-- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/apps/files/src/components/NewNodeDialog.spec.ts b/apps/files/src/components/NewNodeDialog.spec.ts index 77f59a6352f95..bd50b31eac164 100644 --- a/apps/files/src/components/NewNodeDialog.spec.ts +++ b/apps/files/src/components/NewNodeDialog.spec.ts @@ -7,30 +7,33 @@ import { cleanup, fireEvent, render } from '@testing-library/vue' import { afterEach, describe, expect, it, vi } from 'vitest' import NewNodeDialog from './NewNodeDialog.vue' +vi.mock('@nextcloud/capabilities') + describe('NewNodeDialog', () => { afterEach(cleanup) - it('shows a single inline error for a duplicate name without reporting native validity', async () => { + it('reports a duplicate name using the native validation only', async () => { + const reportValidity = vi.spyOn(HTMLInputElement.prototype, 'reportValidity') + const setCustomValidity = vi.spyOn(HTMLInputElement.prototype, 'setCustomValidity') const component = render(NewNodeDialog, { props: { otherNames: ['existing.txt'], }, }) const input = component.getByRole('textbox', { name: 'Folder name' }) as HTMLInputElement - const reportValidity = vi.spyOn(input, 'reportValidity') - const setCustomValidity = vi.spyOn(input, 'setCustomValidity') await fireEvent.update(input, 'existing.txt') - expect(component.getAllByText('This name is already in use.')).toHaveLength(1) - expect(component.getByRole('button', { name: 'Create' })).toBeDisabled() expect(setCustomValidity).toHaveBeenLastCalledWith('This name is already in use.') expect(input.validity.valid).toBe(false) expect(input.validationMessage).toBe('This name is already in use.') - expect(reportValidity).not.toHaveBeenCalled() + expect(reportValidity).toHaveBeenCalled() + // the message is only shown by the platform, not duplicated as helper text + expect(component.queryByText('This name is already in use.')).not.toBeInTheDocument() + expect(component.getByRole('button', { name: 'Create' })).toBeDisabled() }) - it('clears inline and native validity when the name becomes unique', async () => { + it('clears the native validity when the name becomes unique', async () => { const component = render(NewNodeDialog, { props: { otherNames: ['existing.txt'], @@ -43,23 +46,39 @@ describe('NewNodeDialog', () => { await fireEvent.update(input, 'unique.txt') - expect(component.queryByText('This name is already in use.')).not.toBeInTheDocument() expect(input.validity.valid).toBe(true) expect(input.validationMessage).toBe('') expect(component.getByRole('button', { name: 'Create' })).toBeEnabled() }) - it('shows other filename errors inline without reporting native validity', async () => { + it('reports other filename errors using the native validation only', async () => { + const reportValidity = vi.spyOn(HTMLInputElement.prototype, 'reportValidity') const component = render(NewNodeDialog) const input = component.getByRole('textbox', { name: 'Folder name' }) as HTMLInputElement - const reportValidity = vi.spyOn(input, 'reportValidity') await fireEvent.update(input, '') - expect(component.getAllByText('Filename must not be empty.')).toHaveLength(1) - expect(component.getByRole('button', { name: 'Create' })).toBeDisabled() expect(input.validity.valid).toBe(false) expect(input.validationMessage).toBe('Filename must not be empty.') - expect(reportValidity).not.toHaveBeenCalled() + expect(reportValidity).toHaveBeenCalled() + expect(component.queryByText('Filename must not be empty.')).not.toBeInTheDocument() + expect(component.getByRole('button', { name: 'Create' })).toBeDisabled() + }) + + it('does not submit a duplicate name', async () => { + const component = render(NewNodeDialog, { + props: { + otherNames: ['existing.txt'], + }, + }) + const input = component.getByRole('textbox', { name: 'Folder name' }) as HTMLInputElement + const form = input.closest('form') as HTMLFormElement + + await fireEvent.update(input, 'existing.txt') + // same code path as pressing enter within the form + form.requestSubmit() + + expect(form.checkValidity()).toBe(false) + expect(component.emitted().close).toBeUndefined() }) }) diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue index 8e5380ea96e35..c89af6957428f 100644 --- a/apps/files/src/components/NewNodeDialog.vue +++ b/apps/files/src/components/NewNodeDialog.vue @@ -27,8 +27,6 @@ ref="nameInput" v-model="localDefaultName" data-cy-files-new-node-dialog-input - :error="validity !== ''" - :helper-text="validity" :label="label" /> @@ -147,6 +145,7 @@ watchEffect(() => { const input = nameInput.value?.$el.querySelector('input') if (input) { input.setCustomValidity(validity.value) + input.reportValidity() } }) @@ -166,7 +165,7 @@ onMounted(() => {