Skip to content

Commit 0eb5717

Browse files
committed
fix(files): Ensure renaming state is correctly reset
Problem: Is a node is renamed and the new name is out of the current visible list of nodes the component will be recycled, this means the props will change, so when the `onRename` functions is about to reset the state the `this.source` will point to a different node. To fix this, but also to separate business logic from visual representation, the logic is moved into the renaming store and the component is only responsible for rendering. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 73c31a9 commit 0eb5717

4 files changed

Lines changed: 172 additions & 59 deletions

File tree

apps/files/src/components/FileEntry/FileEntryName.vue

Lines changed: 13 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,8 @@
5757
import type { FileAction, Node } from '@nextcloud/files'
5858
import type { PropType } from 'vue'
5959
60-
import axios from '@nextcloud/axios'
6160
import { showError, showSuccess } from '@nextcloud/dialogs'
62-
import { emit } from '@nextcloud/event-bus'
63-
import { FileType, NodeStatus } from '@nextcloud/files'
61+
import { FileType } from '@nextcloud/files'
6462
import { translate as t } from '@nextcloud/l10n'
6563
import { defineComponent, inject } from 'vue'
6664
@@ -260,64 +258,23 @@ export default defineComponent({
260258
}
261259
262260
const oldName = this.source.basename
263-
const oldEncodedSource = this.source.encodedSource
264-
if (oldName === newName) {
265-
this.stopRenaming()
266-
return
267-
}
268-
269-
// Set loading state
270-
this.loading = 'renaming'
271-
this.$set(this.source, 'status', NodeStatus.LOADING)
272261
273-
// Update node
274-
this.source.rename(newName)
275-
276-
logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource })
277262
try {
278-
await axios({
279-
method: 'MOVE',
280-
url: oldEncodedSource,
281-
headers: {
282-
Destination: this.source.encodedSource,
283-
Overwrite: 'F',
284-
},
285-
})
286-
287-
// Success 🎉
288-
emit('files:node:updated', this.source)
289-
emit('files:node:renamed', this.source)
290-
showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
291-
292-
// Reset the renaming store
293-
this.stopRenaming()
294-
this.$nextTick(() => {
295-
const nameContainter = this.$refs.basename as HTMLElement | undefined
296-
nameContainter?.focus()
297-
})
263+
const status = await this.renamingStore.rename()
264+
if (status) {
265+
showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
266+
this.$nextTick(() => {
267+
const nameContainter = this.$refs.basename as HTMLElement | undefined
268+
nameContainter?.focus()
269+
})
270+
} else {
271+
// Was cancelled - meaning the renaming state is just reset
272+
}
298273
} catch (error) {
299-
logger.error('Error while renaming file', { error })
300-
// Rename back as it failed
301-
this.source.rename(oldName)
274+
logger.error(error as Error)
275+
showError((error as Error).message)
302276
// And ensure we reset to the renaming state
303277
this.startRenaming()
304-
305-
if (isAxiosError(error)) {
306-
// TODO: 409 means current folder does not exist, redirect ?
307-
if (error?.response?.status === 404) {
308-
showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
309-
return
310-
} else if (error?.response?.status === 412) {
311-
showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.directory }))
312-
return
313-
}
314-
}
315-
316-
// Unknown error
317-
showError(t('files', 'Could not rename "{oldName}"', { oldName }))
318-
} finally {
319-
this.loading = false
320-
this.$set(this.source, 'status', undefined)
321278
}
322279
},
323280

apps/files/src/store/renaming.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,96 @@
1919
* along with this program. If not, see <http://www.gnu.org/licenses/>.
2020
*
2121
*/
22-
import { defineStore } from 'pinia'
23-
import { subscribe } from '@nextcloud/event-bus'
2422
import type { Node } from '@nextcloud/files'
2523
import type { RenamingStore } from '../types'
2624

25+
import axios, { isAxiosError } from '@nextcloud/axios'
26+
import { emit, subscribe } from '@nextcloud/event-bus'
27+
import { NodeStatus } from '@nextcloud/files'
28+
import { t } from '@nextcloud/l10n'
29+
import { basename, dirname } from 'path'
30+
import { defineStore } from 'pinia'
31+
import logger from '../logger'
32+
import Vue from 'vue'
33+
2734
export const useRenamingStore = function(...args) {
2835
const store = defineStore('renaming', {
2936
state: () => ({
3037
renamingNode: undefined,
3138
newName: '',
3239
} as RenamingStore),
40+
41+
actions: {
42+
/**
43+
* Execute the renaming.
44+
* This will rename the node set as `renamingNode` to the configured new name `newName`.
45+
* @return true if success, false if skipped (e.g. new and old name are the same)
46+
* @throws Error if renaming fails, details are set in the error message
47+
*/
48+
async rename(): Promise<boolean> {
49+
if (this.renamingNode === undefined) {
50+
throw new Error('No node is currently being renamed')
51+
}
52+
53+
const newName = this.newName.trim?.() || ''
54+
const oldName = this.renamingNode.basename
55+
const oldEncodedSource = this.renamingNode.encodedSource
56+
if (oldName === newName) {
57+
return false
58+
}
59+
60+
const node = this.renamingNode
61+
Vue.set(node, 'status', NodeStatus.LOADING)
62+
63+
try {
64+
// rename the node
65+
this.renamingNode.rename(newName)
66+
logger.debug('Moving file to', { destination: this.renamingNode.encodedSource, oldEncodedSource })
67+
// create MOVE request
68+
await axios({
69+
method: 'MOVE',
70+
url: oldEncodedSource,
71+
headers: {
72+
Destination: this.renamingNode.encodedSource,
73+
Overwrite: 'F',
74+
},
75+
})
76+
77+
// Success 🎉
78+
emit('files:node:updated', this.renamingNode as Node)
79+
emit('files:node:renamed', this.renamingNode as Node)
80+
emit('files:node:moved', {
81+
node: this.renamingNode as Node,
82+
oldSource: `${dirname(this.renamingNode.source)}/${oldName}`,
83+
})
84+
this.$reset()
85+
return true
86+
} catch (error) {
87+
logger.error('Error while renaming file', { error })
88+
// Rename back as it failed
89+
this.renamingNode.rename(oldName)
90+
if (isAxiosError(error)) {
91+
// TODO: 409 means current folder does not exist, redirect ?
92+
if (error?.response?.status === 404) {
93+
throw new Error(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
94+
} else if (error?.response?.status === 412) {
95+
throw new Error(t(
96+
'files',
97+
'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.',
98+
{
99+
newName,
100+
dir: basename(this.renamingNode.dirname),
101+
},
102+
))
103+
}
104+
}
105+
// Unknown error
106+
throw new Error(t('files', 'Could not rename "{oldName}"', { oldName }))
107+
} finally {
108+
Vue.set(node, 'status', undefined)
109+
}
110+
},
111+
},
33112
})
34113

35114
const renamingStore = store(...args)

cypress/e2e/files/files-renaming.cy.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import type { User } from '@nextcloud/cypress'
7-
import { getRowForFile, triggerActionForFile } from './FilesUtils'
7+
import { getRowForFile, renameFile, triggerActionForFile } from './FilesUtils'
88

99
const haveValidity = (validity: string | RegExp) => {
1010
if (typeof validity === 'string') {
@@ -74,4 +74,80 @@ describe('files: Rename nodes', { testIsolation: true }, () => {
7474
// See validity
7575
.should(haveValidity(/reserved name/i))
7676
})
77+
78+
it('shows accessible loading information', () => {
79+
const { resolve, promise } = Promise.withResolvers()
80+
81+
getRowForFile('file.txt').should('be.visible')
82+
83+
// intercept the rename (MOVE)
84+
// the callback will wait until the promise resolve (so we have time to check the loading state)
85+
cy.intercept(
86+
'MOVE',
87+
/\/remote.php\/dav\/files\//,
88+
(request) => {
89+
// we need to wait in the onResponse handler as the intercept handler times out otherwise
90+
request.on('response', async () => { await promise })
91+
},
92+
).as('moveFile')
93+
94+
// Start the renaming
95+
triggerActionForFile('file.txt', 'rename')
96+
getRowForFile('file.txt')
97+
.findByRole('textbox', { name: 'Filename' })
98+
.should('be.visible')
99+
.type('{selectAll}new-name.txt{enter}')
100+
101+
// Loading state is visible
102+
getRowForFile('new-name.txt')
103+
.findByRole('img', { name: 'File is loading' })
104+
.should('be.visible')
105+
// checkbox is not visible
106+
getRowForFile('new-name.txt')
107+
.findByRole('checkbox', { name: /^Toggle selection/ })
108+
.should('not.exist')
109+
110+
cy.log('Resolve promise to preoceed with MOVE request')
111+
.then(() => resolve(null))
112+
113+
// Ensure the request is done (file renamed)
114+
cy.wait('@moveFile')
115+
116+
// checkbox visible again
117+
getRowForFile('new-name.txt')
118+
.findByRole('checkbox', { name: /^Toggle selection/ })
119+
.should('exist')
120+
// see the loading state is gone
121+
getRowForFile('new-name.txt')
122+
.findByRole('img', { name: 'File is loading' })
123+
.should('not.exist')
124+
})
125+
126+
/**
127+
* This is a regression test of: https://github.com/nextcloud/server/issues/47438
128+
* The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list
129+
* due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling).
130+
*/
131+
it('correctly resets renaming state', () => {
132+
for (let i = 1; i <= 20; i++) {
133+
cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`)
134+
}
135+
cy.viewport(1200, 500) // 500px is smaller then 20 * 50 which is the place that the files take up
136+
cy.login(user)
137+
cy.visit('/apps/files')
138+
139+
getRowForFile('file.txt').should('be.visible')
140+
// Z so it is shown last
141+
renameFile('file.txt', 'zzz.txt')
142+
// not visible any longer
143+
getRowForFile('zzz.txt').should('not.be.visible')
144+
// scroll file list to bottom
145+
cy.get('[data-cy-files-list]').scrollTo('bottom')
146+
cy.screenshot()
147+
// The file is no longer in rename state
148+
getRowForFile('zzz.txt')
149+
.should('be.visible')
150+
.findByRole('textbox', { name: 'Filename' })
151+
.should('not.exist')
152+
})
77153
})

cypress/support/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { basename } from 'path'
3030
import '@testing-library/cypress/add-commands'
3131
import 'cypress-if'
3232
import 'cypress-wait-until'
33+
3334
addCommands()
3435

3536
// Register this file's custom commands types

0 commit comments

Comments
 (0)