Skip to content

Commit

Permalink
Merge pull request #6006 from nextcloud-libraries/feat/ncdialog-reset
Browse files Browse the repository at this point in the history
feat(NcDialog): Allow to catch `reset` event
  • Loading branch information
susnux authored Feb 28, 2025
2 parents 11f10b0 + 1e64c30 commit 3779b88
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 11 deletions.
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"
:open.sync="showDialog"
@submit="currentName = newName"
@reset="newName = ''"
@closing="newName = ''">
<NcTextField label="New name"
placeholder="Min. 6 characters"
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 @@ -375,7 +380,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 @@ -419,7 +424,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 @@ -429,7 +434,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 @@ -509,6 +514,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 @@ -521,12 +536,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
11 changes: 8 additions & 3 deletions src/components/NcDialogButton/NcDialogButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@ Dialog button component used by NcDialog in the actions slot to display the butt

<script setup>
import { ref } from 'vue'
import { t } from '../../l10n.js'

import NcButton from '../NcButton/index.js'
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 @@ -109,7 +112,9 @@ const handleClick = async (e) => {

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 @@ -4,6 +4,8 @@
*/

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

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

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

describe('NcDialogButton', () => {
it.each([
['reset'],
['button'],
['submit'],
])('forwards the native type', async (nativeType: string) => {
const wrapper = mount(NcDialogButton, {
propsData: {
label: 'button',
nativeType,
},
})
expect(wrapper.find('button').attributes('type')).toBe(nativeType)
})

it('handles click', async () => {
const wrapper = mount(NcDialogButton, {
propsData: {
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, {
propsData: {
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, {
propsData: {
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, {
propsData: {
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, {
propsData: {
label: 'button',
nativeType: '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, {
propsData: {
label: 'button',
nativeType: '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, {
propsData: {
label: 'button',
callback: () => promise,
},
})
// click the button
const button = wrapper.find('button')
await button.trigger('click')
// 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 callbacl
resolve()
await nextTick()
// see there is the event now
expect(wrapper.emitted('click')).toHaveLength(1)
// 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 3779b88

Please sign in to comment.