Skip to content

Commit 3b4bc45

Browse files
committed
fix(settings): add loading feedback to account create/edit dialogs
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent b6d0e19 commit 3b4bc45

5 files changed

Lines changed: 346 additions & 5 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { mount } from '@vue/test-utils'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const confirmPassword = vi.hoisted(() => vi.fn())
10+
vi.mock('@nextcloud/password-confirmation', () => ({ confirmPassword }))
11+
vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn(), showSuccess: vi.fn() }))
12+
13+
// Decouple the dialog test from form-data diffing internals: always report a
14+
// non-empty change set so save() proceeds past its early return. Other exports
15+
// (used transitively by the form sub-components) are kept real.
16+
vi.mock('./userFormUtils.ts', async (importActual) => ({
17+
...(await importActual()),
18+
userToFormData: () => ({
19+
username: 'bob',
20+
displayName: 'Bob',
21+
password: '',
22+
email: '',
23+
groups: [],
24+
subadminGroups: [],
25+
quota: { id: 'default' },
26+
language: { code: 'en' },
27+
manager: { id: '' },
28+
}),
29+
diffPayload: () => ({ displayName: 'Bobby' }),
30+
}))
31+
32+
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
33+
import EditUserDialog from './EditUserDialog.vue'
34+
import { flushPromises, NcButtonStub, NcDialogStub, UserFormFieldsStub } from './dialogTestHelpers.ts'
35+
36+
function mountDialog({ dispatch = vi.fn() } = {}) {
37+
return mount(EditUserDialog, {
38+
propsData: {
39+
user: { id: 'bob', backendCapabilities: { setPassword: true } },
40+
quotaOptions: [],
41+
},
42+
mocks: {
43+
t: (_app: string, text: string) => text,
44+
$store: {
45+
dispatch,
46+
getters: {
47+
getGroups: [],
48+
getServerData: { languages: [], canChangePassword: true },
49+
getPasswordPolicyMinLength: 8,
50+
},
51+
},
52+
},
53+
stubs: {
54+
NcDialog: NcDialogStub,
55+
NcButton: NcButtonStub,
56+
UserFormFields: UserFormFieldsStub,
57+
},
58+
})
59+
}
60+
61+
describe('EditUserDialog loading feedback', () => {
62+
beforeEach(() => {
63+
vi.clearAllMocks()
64+
confirmPassword.mockResolvedValue(undefined)
65+
})
66+
67+
it('does not dispatch a second save request while one is in flight', async () => {
68+
const dispatch = vi.fn().mockReturnValue(new Promise(() => {}))
69+
const wrapper = mountDialog({ dispatch })
70+
71+
await wrapper.find('form').trigger('submit')
72+
await flushPromises()
73+
await wrapper.find('form').trigger('submit')
74+
await flushPromises()
75+
76+
const saveCalls = dispatch.mock.calls.filter(([action]) => action === 'editUserMultiField')
77+
expect(saveCalls).toHaveLength(1)
78+
})
79+
80+
it('marks the form as busy and inert while saving', async () => {
81+
confirmPassword.mockReturnValue(new Promise(() => {}))
82+
const wrapper = mountDialog()
83+
84+
await wrapper.find('form').trigger('submit')
85+
86+
const form = wrapper.find('form')
87+
expect(form.attributes('aria-busy')).toBe('true')
88+
expect(form.attributes('inert')).toBeDefined()
89+
})
90+
91+
it('shows a spinner and busy label on the submit button while saving', async () => {
92+
confirmPassword.mockReturnValue(new Promise(() => {}))
93+
const wrapper = mountDialog()
94+
95+
await wrapper.find('form').trigger('submit')
96+
97+
expect(wrapper.findComponent(NcLoadingIcon).exists()).toBe(true)
98+
expect(wrapper.find('[data-test="submit"]').text()).toContain('Saving')
99+
})
100+
101+
it('sets aria-disabled (not disabled) on the submit button while saving', async () => {
102+
confirmPassword.mockReturnValue(new Promise(() => {}))
103+
const wrapper = mountDialog()
104+
const submit = wrapper.find('[data-test="submit"]')
105+
106+
expect(submit.attributes('aria-disabled')).toBe('false')
107+
expect(submit.attributes('disabled')).toBeUndefined()
108+
109+
await wrapper.find('form').trigger('submit')
110+
111+
expect(submit.attributes('aria-disabled')).toBe('true')
112+
expect(submit.attributes('disabled')).toBeUndefined()
113+
})
114+
115+
it('prevents closing the dialog while saving', async () => {
116+
confirmPassword.mockReturnValue(new Promise(() => {}))
117+
const wrapper = mountDialog()
118+
const dialog = wrapper.findComponent(NcDialogStub)
119+
120+
expect(dialog.props('noClose')).toBe(false)
121+
122+
await wrapper.find('form').trigger('submit')
123+
124+
expect(dialog.props('noClose')).toBe(true)
125+
})
126+
})

apps/settings/src/components/Users/EditUserDialog.vue

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
size="small"
1010
:name="t('settings', 'Edit account')"
1111
outTransition
12+
:noClose="saving"
1213
@closing="$emit('closing')">
1314
<form
1415
id="edit-user-form"
1516
class="edit-dialog__form"
1617
data-test="form"
17-
:disabled="saving"
18+
:inert="saving"
19+
:aria-busy="saving ? 'true' : 'false'"
1820
@submit.prevent="save">
1921
<UserFormFields
2022
:fieldConfig="fieldConfig"
@@ -29,7 +31,10 @@
2931
form="edit-user-form"
3032
variant="primary"
3133
type="submit"
32-
:disabled="saving">
34+
:aria-disabled="saving ? 'true' : 'false'">
35+
<template v-if="saving" #icon>
36+
<NcLoadingIcon />
37+
</template>
3338
{{ saving ? t('settings', 'Saving\u00A0…') : t('settings', 'Save') }}
3439
</NcButton>
3540
</template>
@@ -41,6 +46,7 @@ import { showError, showSuccess } from '@nextcloud/dialogs'
4146
import { confirmPassword } from '@nextcloud/password-confirmation'
4247
import NcButton from '@nextcloud/vue/components/NcButton'
4348
import NcDialog from '@nextcloud/vue/components/NcDialog'
49+
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
4450
import UserFormFields from './UserFormFields.vue'
4551
import logger from '../../logger.ts'
4652
import { diffPayload, userToFormData } from './userFormUtils.ts'
@@ -51,6 +57,7 @@ export default {
5157
components: {
5258
NcButton,
5359
NcDialog,
60+
NcLoadingIcon,
5461
UserFormFields,
5562
},
5663
@@ -113,6 +120,11 @@ export default {
113120
114121
methods: {
115122
async save() {
123+
// Guard against re-submit while a request is already running. The
124+
// button is only aria-disabled (not disabled), so it can still fire.
125+
if (this.saving) {
126+
return
127+
}
116128
this.fieldErrors = {}
117129
118130
const payload = diffPayload(this.initialData, this.editedUser)
@@ -152,4 +164,9 @@ export default {
152164
margin-block-start: calc(var(--default-grid-baseline, 4px) * 3);
153165
}
154166
}
167+
168+
// Visually communicate the locked/busy form while the account is saved.
169+
.edit-dialog__form[aria-busy='true'] {
170+
opacity: 0.5;
171+
}
155172
</style>
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { mount } from '@vue/test-utils'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
9+
import NewUserDialog from './NewUserDialog.vue'
10+
import { flushPromises, NcButtonStub, NcDialogStub, UserFormFieldsStub } from './dialogTestHelpers.ts'
11+
12+
function makeNewUser(overrides = {}) {
13+
return {
14+
username: 'alice',
15+
displayName: '',
16+
password: 'secret-password',
17+
email: '',
18+
groups: [],
19+
subadminGroups: [],
20+
quota: { id: 'default' },
21+
language: { code: 'en' },
22+
manager: { id: '' },
23+
...overrides,
24+
}
25+
}
26+
27+
function mountDialog({ dispatch = vi.fn(), loading = { all: false } } = {}) {
28+
return mount(NewUserDialog, {
29+
propsData: {
30+
loading,
31+
newUser: makeNewUser(),
32+
quotaOptions: [],
33+
},
34+
mocks: {
35+
t: (_app: string, text: string) => text,
36+
$store: {
37+
dispatch,
38+
getters: {
39+
getServerData: { newUserGenerateUserID: false, newUserRequireEmail: false },
40+
getPasswordPolicyMinLength: 8,
41+
},
42+
},
43+
},
44+
stubs: {
45+
NcDialog: NcDialogStub,
46+
NcButton: NcButtonStub,
47+
UserFormFields: UserFormFieldsStub,
48+
},
49+
})
50+
}
51+
52+
describe('NewUserDialog loading feedback', () => {
53+
beforeEach(() => {
54+
vi.clearAllMocks()
55+
})
56+
57+
it('does not dispatch a second create request while one is in flight', async () => {
58+
const dispatch = vi.fn().mockReturnValue(new Promise(() => {}))
59+
const wrapper = mountDialog({ dispatch })
60+
61+
await wrapper.find('form').trigger('submit')
62+
await wrapper.find('form').trigger('submit')
63+
64+
const addUserCalls = dispatch.mock.calls.filter(([action]) => action === 'addUser')
65+
expect(addUserCalls).toHaveLength(1)
66+
})
67+
68+
it('marks the form as busy and inert while creating', async () => {
69+
const dispatch = vi.fn().mockReturnValue(new Promise(() => {}))
70+
const wrapper = mountDialog({ dispatch })
71+
72+
await wrapper.find('form').trigger('submit')
73+
74+
const form = wrapper.find('form')
75+
expect(form.attributes('aria-busy')).toBe('true')
76+
expect(form.attributes('inert')).toBeDefined()
77+
})
78+
79+
it('shows a spinner and busy label on the submit button while creating', async () => {
80+
const dispatch = vi.fn().mockReturnValue(new Promise(() => {}))
81+
const wrapper = mountDialog({ dispatch })
82+
83+
await wrapper.find('form').trigger('submit')
84+
85+
expect(wrapper.findComponent(NcLoadingIcon).exists()).toBe(true)
86+
expect(wrapper.find('[data-test="submit"]').text()).toContain('Adding new account')
87+
})
88+
89+
it('sets aria-disabled (not disabled) on the submit button while creating', async () => {
90+
const dispatch = vi.fn().mockReturnValue(new Promise(() => {}))
91+
const wrapper = mountDialog({ dispatch })
92+
const submit = wrapper.find('[data-test="submit"]')
93+
94+
expect(submit.attributes('aria-disabled')).toBe('false')
95+
expect(submit.attributes('disabled')).toBeUndefined()
96+
97+
await wrapper.find('form').trigger('submit')
98+
99+
expect(submit.attributes('aria-disabled')).toBe('true')
100+
expect(submit.attributes('disabled')).toBeUndefined()
101+
})
102+
103+
it('prevents closing the dialog while creating', async () => {
104+
const dispatch = vi.fn().mockReturnValue(new Promise(() => {}))
105+
const wrapper = mountDialog({ dispatch })
106+
const dialog = wrapper.findComponent(NcDialogStub)
107+
108+
expect(dialog.props('noClose')).toBe(false)
109+
110+
await wrapper.find('form').trigger('submit')
111+
112+
expect(dialog.props('noClose')).toBe(true)
113+
})
114+
115+
it('re-enables the form when the request fails', async () => {
116+
const error = { response: { data: { ocs: { meta: { statuscode: 0 } } } } }
117+
const dispatch = vi.fn().mockRejectedValue(error)
118+
const loading = { all: false }
119+
const wrapper = mountDialog({ dispatch, loading })
120+
121+
await wrapper.find('form').trigger('submit')
122+
await flushPromises()
123+
124+
expect(loading.all).toBe(false)
125+
expect(wrapper.find('form').attributes('aria-busy')).toBe('false')
126+
})
127+
})

0 commit comments

Comments
 (0)