Skip to content

Commit

Permalink
Merge pull request #6564 from nextcloud-libraries/backport/6006/next
Browse files Browse the repository at this point in the history
[next] feat(NcDialog): Allow to catch `reset` event
  • Loading branch information
susnux authored Mar 1, 2025
2 parents f1260e1 + e41c5bb commit 43a30d8
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 16 deletions.
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 @@ Note that this is not possible if the dialog contains a navigation!
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 @@ export default {
newName: '',
currentName: 'none yet.',
buttons: [
{
label: 'Reset',
nativeType: 'reset',
},
{
label: 'Submit',
type: 'primary',
Expand Down Expand Up @@ -244,7 +249,7 @@ export default {
<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 @@ export default defineComponent({
},

/**
* 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 @@ export default defineComponent({
},

/**
* 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 @@ export default defineComponent({
},

/**
* 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 @@ export default defineComponent({
/** 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)
},
}
: {},
)
Expand All @@ -528,12 +543,14 @@ export default defineComponent({
// 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)
})
})

0 comments on commit 43a30d8

Please sign in to comment.