Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/components/NcButton/NcButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -686,9 +686,7 @@ export default defineComponent({
hasIcon
? h('span', {
class: 'button-vue__icon',
attrs: {
'aria-hidden': 'true',
},
'aria-hidden': 'true',
},
[this.$slots.icon?.()],
)
Expand Down
33 changes: 25 additions & 8 deletions src/components/NcDialog/NcDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
name="Choose a name"
v-model:open="showDialog"
@submit="currentName = newName"
@reset="newName = ''"
@closing="newName = ''">
<NcTextField v-model="newName"
label="New name"
Expand All @@ -115,6 +116,10 @@
newName: '',
currentName: 'none yet.',
buttons: [
{
label: 'Reset',
nativeType: 'reset',
},
{
label: 'Submit',
type: 'primary',
Expand Down Expand Up @@ -244,7 +249,7 @@
<NcDialogButton v-for="(button, idx) in buttons"
:key="idx"
v-bind="button"
@click="handleButtonClose" />
@click="(_, result) => handleButtonClose(button, result)" />
</slot>
</div>
</component>
Expand Down Expand Up @@ -383,7 +388,7 @@
},

/**
* Optionally pass additionaly classes which will be set on the navigation for custom styling
* Optionally pass additional classes which will be set on the navigation for custom styling
* @default ''
* @example
* ```html
Expand Down Expand Up @@ -427,7 +432,7 @@
},

/**
* Optionally pass additionaly classes which will be set on the content wrapper for custom styling
* Optionally pass additional classes which will be set on the content wrapper for custom styling
* @default ''
*/
contentClasses: {
Expand All @@ -437,7 +442,7 @@
},

/**
* Optionally pass additionaly classes which will be set on the dialog itself
* Optionally pass additional classes which will be set on the dialog itself
* (the default `class` attribute will be set on the modal wrapper)
* @default ''
*/
Expand Down Expand Up @@ -516,6 +521,16 @@
/** Forwarded HTMLFormElement submit event (only if `is-form` is set) */
emit('submit', event)
},
/**
* @param {Event} event Form submit event
*/
reset(event) {
event.preventDefault()
/**
* Forwarded HTMLFormElement reset event (only if `is-form` is set).
*/
emit('reset', event)

Check warning on line 532 in src/components/NcDialog/NcDialog.vue

View workflow job for this annotation

GitHub Actions / NPM lint

The "reset" event has been triggered but not declared on `emits` option
},
}
: {},
)
Expand All @@ -528,12 +543,14 @@
// Because NcModal does not emit `close` when show prop is changed
/**
* Handle clicking a dialog button -> should close
* @param {MouseEvent} event The click event
* @param {MouseEvent} button The button that was clicked
* @param {unknown} result Result of the callback function
*/
const handleButtonClose = (event, result) => {
// Skip close if invalid dialog
if (dialogTagName.value === 'form' && !dialogElement.value.reportValidity()) {
function handleButtonClose(button, result) {
// Skip close on submit if invalid dialog
if (button.nativeType === 'submit'
&& dialogTagName.value === 'form'
&& !dialogElement.value.reportValidity()) {
return
}
handleClosing(result)
Expand Down
14 changes: 9 additions & 5 deletions src/components/NcDialogButton/NcDialogButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,18 @@ Dialog button component used by NcDialog in the actions slot to display the butt
<script setup lang="ts">
import type { PropType } from 'vue'
import { ref } from 'vue'
import { t } from '../../l10n.js'

import NcButton, { ButtonNativeType, ButtonType } from '../NcButton/index'
import NcIconSvgWrapper from '../NcIconSvgWrapper/index.js'
import NcLoadingIcon from '../NcLoadingIcon/index.js'
import { t } from '../../l10n.js'

const props = defineProps({
/**
* The function that will be called when the button is pressed.
* If the function returns `false` the click is ignored and the dialog will not be closed.
* If the function returns `false` the click is ignored and the dialog will not be closed,
* which is the default behavior of "reset"-buttons.
*
* @type {() => unknown|false|Promise<unknown|false>}
*/
callback: {
Expand Down Expand Up @@ -108,17 +110,19 @@ const isLoading = ref(false)

/**
* Handle clicking the button
* @param {MouseEvent} e The click event
* @param e The click event
*/
const handleClick = async (e) => {
async function handleClick(e: MouseEvent) {
// Do not re-emit while loading
if (isLoading.value) {
return
}

isLoading.value = true
try {
const result = await props.callback?.()
// for reset buttons the default is "false"
const fallback = props.nativeType === 'reset' ? false : undefined
const result = await props.callback?.() ?? fallback
if (result !== false) {
/**
* The click event (`MouseEvent`) and the value returned by the callback
Expand Down
2 changes: 2 additions & 0 deletions tests/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import { vi } from 'vitest'
import OC from './OC.js'
// TODO: Remove when we support Node 22
import 'core-js/actual/promise/with-resolvers.js'

vi.stubGlobal('OC', OC)
vi.stubGlobal('appName', 'nextcloud-vue')
Expand Down
123 changes: 123 additions & 0 deletions tests/unit/components/NcDialogButton/NcDialogButton.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { ButtonNativeType } from '../../../../src/components/NcButton'
import NcDialogButton from '../../../../src/components/NcDialogButton/NcDialogButton.vue'

describe('NcDialogButton', () => {
it.each([
[ButtonNativeType.Reset],
[ButtonNativeType.Button],
[ButtonNativeType.Submit],
])('forwards the native type', async (nativeType: ButtonNativeType) => {
const wrapper = mount(NcDialogButton, {
props: {
label: 'button',
nativeType,
},
})
expect(wrapper.find('button').attributes('type')).toBe(nativeType)
})

it('handles click', async () => {
const wrapper = mount(NcDialogButton, {
props: {
label: 'button',
},
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.emitted('click')![0]).toHaveLength(2)
})

it('has mouse event as click payload', async () => {
const wrapper = mount(NcDialogButton, {
props: {
label: 'button',
},
})
const event = { id: 'my-event' }
await wrapper.find('button').trigger('click', event)
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.emitted('click')![0][0]).toMatchObject(event)
})

it('has callback response as second click event payload', async () => {
const wrapper = mount(NcDialogButton, {
props: {
label: 'button',
callback: () => 'payload',
},
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.emitted('click')![0][1]).toBe('payload')
})

it('callback defaults to undefined', async () => {
const wrapper = mount(NcDialogButton, {
props: {
label: 'button',
},
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.emitted('click')![0]).toHaveLength(2)
expect(wrapper.emitted('click')![0][1]).toBeUndefined()
})

it('reset-button callback defaults to false', async () => {
const wrapper = mount(NcDialogButton, {
props: {
label: 'button',
nativeType: ButtonNativeType.Reset,
},
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('click')).toBe(undefined)
})

it('reset-button with callback emits click', async () => {
const wrapper = mount(NcDialogButton, {
props: {
label: 'button',
nativeType: ButtonNativeType.Reset,
callback: () => true,
},
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})

it('has a loading state while the callback is awaited', async () => {
const { promise, resolve } = Promise.withResolvers<void>()
const wrapper = mount(NcDialogButton, {
props: {
label: 'button',
callback: () => promise,
},
})
// click the button
const button = wrapper.find('button')
await button.trigger('click')
await nextTick()
// no event because it is still resolving
expect(wrapper.emitted('click')).toBeUndefined()
// see there is the loading indicator
expect(button.find('[aria-label="Loading …"]').exists()).toBe(true)
// resolve the callback
resolve()
await nextTick()
// see there is the event now
expect(wrapper.emitted('click')).toHaveLength(1)
await nextTick()
// and the loading indicator is gone
// see there is the loading indicator
expect(button.find('[aria-label="Loading …"]').exists()).toBe(false)
})
})
Loading