enh(files): Add modal to set filename before creating new files in the fileslist

* Reactive `openfile` query

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/42993/head
Ferdinand Thiessen 4 months ago
parent e62c5d719d
commit 8be4704e11
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400

@ -21,7 +21,11 @@
-->
<template>
<tr :class="{'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
<tr :class="{
'files-list__row--dragover': dragover,
'files-list__row--loading': isLoading,
'files-list__row--active': isActive,
}"
data-cy-files-list-row
:data-cy-files-list-row-fileid="fileid"
:data-cy-files-list-row-name="source.basename"
@ -97,7 +101,7 @@
<script lang="ts">
import { defineComponent } from 'vue'
import { formatFileSize } from '@nextcloud/files'
import { Permission, formatFileSize } from '@nextcloud/files'
import moment from '@nextcloud/moment'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
@ -232,6 +236,13 @@ export default defineComponent({
}
return ''
},
/**
* This entry is the current active node
*/
isActive() {
return this.fileid === this.currentFileId?.toString?.()
},
},
methods: {

@ -139,6 +139,7 @@ export default defineComponent({
FileEntryGrid,
headers: getFileListHeaders(),
scrollToIndex: 0,
openFileId: null as number|null,
}
},
@ -151,6 +152,14 @@ export default defineComponent({
return parseInt(this.$route.params.fileid) || null
},
/**
* If the current `fileId` should be opened
* The state of the `openfile` query param
*/
openFile() {
return !!this.$route.query.openfile
},
summary() {
return getSummaryFor(this.nodes)
},
@ -199,6 +208,12 @@ export default defineComponent({
fileId(fileId) {
this.scrollToFile(fileId, false)
},
openFile(open: boolean) {
if (open) {
this.$nextTick(() => this.handleOpenFile(this.fileId))
}
},
},
mounted() {
@ -206,9 +221,11 @@ export default defineComponent({
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.addEventListener('dragover', this.onDragOver)
this.scrollToFile(this.fileId)
this.openSidebarForFile(this.fileId)
this.handleOpenFile()
// handle initially opening a given file
const { id } = loadState<{ id?: number }>('files', 'openFileInfo', {})
this.scrollToFile(id ?? this.fileId)
this.openSidebarForFile(id ?? this.fileId)
this.handleOpenFile(id ?? null)
},
beforeDestroy() {
@ -241,18 +258,22 @@ export default defineComponent({
}
},
handleOpenFile() {
const openFileInfo = loadState('files', 'openFileInfo', {}) as ({ id?: number })
if (openFileInfo === undefined) {
/**
* Handle opening a file (e.g. by ?openfile=true)
* @param fileId File to open
*/
handleOpenFile(fileId: number|null) {
if (fileId === null || this.openFileId === fileId) {
return
}
const node = this.nodes.find(n => n.fileid === openFileInfo.id) as NcNode
const node = this.nodes.find(n => n.fileid === fileId) as NcNode
if (node === undefined || node.type === FileType.Folder) {
return
}
logger.debug('Opening file ' + node.path, { node })
this.openFileId = fileId
getFileActions()
.filter(action => !action.enabled || action.enabled([node], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))

@ -0,0 +1,149 @@
<!--
- @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
-
- @author Ferdinand Thiessen <opensource@fthiessen.de>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<NcDialog :name="name"
:open="open"
close-on-click-outside
out-transition
@update:open="onClose">
<template #actions>
<NcButton type="primary"
:disabled="!isUniqueName"
@click="onCreate">
{{ t('files', 'Create') }}
</NcButton>
</template>
<form @submit.prevent="onCreate">
<NcTextField ref="input"
:error="!isUniqueName"
:helper-text="errorMessage"
:label="label"
:value.sync="localDefaultName" />
</form>
</NcDialog>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import { defineComponent } from 'vue'
import { translate as t } from '@nextcloud/l10n'
import { getUniqueName } from '../utils/fileUtils'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
interface ICanFocus {
focus: () => void
}
export default defineComponent({
name: 'NewNodeDialog',
components: {
NcButton,
NcDialog,
NcTextField,
},
props: {
/**
* The name to be used by default
*/
defaultName: {
type: String,
default: t('files', 'New folder'),
},
/**
* Other files that are in the current directory
*/
otherNames: {
type: Array as PropType<string[]>,
default: () => [],
},
/**
* Open state of the dialog
*/
open: {
type: Boolean,
default: true,
},
/**
* Dialog name
*/
name: {
type: String,
default: t('files', 'Create new folder'),
},
/**
* Input label
*/
label: {
type: String,
default: t('files', 'Folder name'),
},
},
emits: {
close: (name: string|null) => name === null || name,
},
data() {
return {
localDefaultName: this.defaultName || t('files', 'New folder'),
}
},
computed: {
errorMessage() {
if (this.isUniqueName) {
return ''
} else {
return t('files', 'A file or folder with that name already exists.')
}
},
uniqueName() {
return getUniqueName(this.localDefaultName, this.otherNames)
},
isUniqueName() {
return this.localDefaultName === this.uniqueName
},
},
watch: {
defaultName() {
this.localDefaultName = this.defaultName || t('files', 'New folder')
},
},
mounted() {
// on mounted lets use the unique name
this.localDefaultName = this.uniqueName
this.$nextTick(() => (this.$refs.input as unknown as ICanFocus)?.focus?.())
},
methods: {
t,
onCreate() {
this.$emit('close', this.localDefaultName)
},
onClose(state: boolean) {
if (!state) {
this.$emit('close', null)
}
},
},
})
</script>

@ -1,149 +0,0 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Julius Härtl <jus@bitgrid.net>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { Entry } from '@nextcloud/files'
import type { TemplateFile } from './types'
import { Folder, Node, Permission, addNewFileMenuEntry, removeNewFileMenuEntry } from '@nextcloud/files'
import { generateOcsUrl } from '@nextcloud/router'
import { getLoggerBuilder } from '@nextcloud/logger'
import { join } from 'path'
import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import Vue from 'vue'
import PlusSvg from '@mdi/svg/svg/plus.svg?raw'
import TemplatePickerView from './views/TemplatePicker.vue'
import { getUniqueName } from './utils/fileUtils.ts'
import { getCurrentUser } from '@nextcloud/auth'
// Set up logger
const logger = getLoggerBuilder()
.setApp('files')
.detectUser()
.build()
// Add translates functions
Vue.mixin({
methods: {
t,
n,
},
})
// Create document root
const TemplatePickerRoot = document.createElement('div')
TemplatePickerRoot.id = 'template-picker'
document.body.appendChild(TemplatePickerRoot)
// Retrieve and init templates
let templates = loadState<TemplateFile[]>('files', 'templates', [])
let templatesPath = loadState('files', 'templates_path', false)
logger.debug('Templates providers', { templates })
logger.debug('Templates folder', { templatesPath })
// Init vue app
const View = Vue.extend(TemplatePickerView)
const TemplatePicker = new View({
name: 'TemplatePicker',
propsData: {
logger,
},
})
TemplatePicker.$mount('#template-picker')
if (!templatesPath) {
logger.debug('Templates folder not initialized')
addNewFileMenuEntry({
id: 'template-picker',
displayName: t('files', 'Create new templates folder'),
iconSvgInline: PlusSvg,
order: 10,
enabled(context: Folder): boolean {
// Allow creation on your own folders only
if (context.owner !== getCurrentUser()?.uid) {
return false
}
return (context.permissions & Permission.CREATE) !== 0
},
handler(context: Folder, content: Node[]) {
// Check for conflicts
const contentNames = content.map((node: Node) => node.basename)
const name = getUniqueName(t('files', 'Templates'), contentNames)
// Create the template folder
initTemplatesFolder(context, name)
// Remove the menu entry
removeNewFileMenuEntry('template-picker')
},
} as Entry)
}
// Init template files menu
templates.forEach((provider, index) => {
addNewFileMenuEntry({
id: `template-new-${provider.app}-${index}`,
displayName: provider.label,
// TODO: migrate to inline svg
iconClass: provider.iconClass || 'icon-file',
enabled(context: Folder): boolean {
return (context.permissions & Permission.CREATE) !== 0
},
order: 11,
handler(context: Folder, content: Node[]) {
// Check for conflicts
const contentNames = content.map((node: Node) => node.basename)
const name = getUniqueName(provider.label + provider.extension, contentNames)
// Create the file
TemplatePicker.open(name, provider)
},
} as Entry)
})
// Init template folder
const initTemplatesFolder = async function(directory: Folder, name: string) {
const templatePath = join(directory.path, name)
try {
logger.debug('Initializing the templates directory', { templatePath })
const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), {
templatePath,
copySystemTemplates: true,
})
// Go to template directory
window.OCP.Files.Router.goToRoute(
null, // use default route
{ view: 'files', fileid: undefined },
{ dir: templatePath },
)
templates = response.data.ocs.data.templates
templatesPath = response.data.ocs.data.template_path
} catch (error) {
logger.error('Unable to initialize the templates directory')
showError(t('files', 'Unable to initialize the templates directory'))
}
}

@ -31,14 +31,15 @@ import { action as openInFilesAction } from './actions/openInFilesAction'
import { action as renameAction } from './actions/renameAction'
import { action as sidebarAction } from './actions/sidebarAction'
import { action as viewInFolderAction } from './actions/viewInFolderAction'
import { entry as newFolderEntry } from './newMenu/newFolder'
import { entry as newFolderEntry } from './newMenu/newFolder.ts'
import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts'
import { registerTemplateEntries } from './newMenu/newFromTemplate.ts'
import registerFavoritesView from './views/favorites'
import registerRecentView from './views/recent'
import registerFilesView from './views/files'
import registerPreviewServiceWorker from './services/ServiceWorker.js'
import './init-templates'
import { initLivePhotos } from './services/LivePhotos'
@ -56,6 +57,8 @@ registerFileAction(viewInFolderAction)
// Register new menu entry
addNewFileMenuEntry(newFolderEntry)
addNewFileMenuEntry(newTemplatesFolder)
registerTemplateEntries()
// Register files views
registerFavoritesView()

@ -31,7 +31,7 @@ import axios from '@nextcloud/axios'
import FolderPlusSvg from '@mdi/svg/svg/folder-plus.svg?raw'
import { getUniqueName } from '../utils/fileUtils.ts'
import { newNodeName } from '../utils/newNodeDialog'
import logger from '../logger'
type createFolderResponse = {
@ -63,23 +63,27 @@ export const entry = {
iconSvgInline: FolderPlusSvg,
order: 0,
async handler(context: Folder, content: Node[]) {
const contentNames = content.map((node: Node) => node.basename)
const name = getUniqueName(t('files', 'New folder'), contentNames)
const { fileid, source } = await createNewFolder(context, name)
const name = await newNodeName(t('files', 'New folder'), content)
if (name !== null) {
const { fileid, source } = await createNewFolder(context, name)
// Create the folder in the store
const folder = new Folder({
source,
id: fileid,
mtime: new Date(),
owner: getCurrentUser()?.uid || null,
permissions: Permission.ALL,
root: context?.root || '/files/' + getCurrentUser()?.uid,
})
// Create the folder in the store
const folder = new Folder({
source,
id: fileid,
mtime: new Date(),
owner: getCurrentUser()?.uid || null,
permissions: Permission.ALL,
root: context?.root || '/files/' + getCurrentUser()?.uid,
})
showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) }))
logger.debug('Created new folder', { folder, source })
emit('files:node:created', folder)
emit('files:node:rename', folder)
showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) }))
logger.debug('Created new folder', { folder, source })
emit('files:node:created', folder)
window.OCP.Files.Router.goToRoute(
null, // use default route
{ view: 'files', fileid: folder.fileid },
{ dir: context.path },
)
}
},
} as Entry

@ -0,0 +1,88 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Julius Härtl <jus@bitgrid.net>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { Entry } from '@nextcloud/files'
import type { ComponentInstance } from 'vue'
import type { TemplateFile } from '../types.ts'
import { Folder, Node, Permission, addNewFileMenuEntry } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { newNodeName } from '../utils/newNodeDialog'
import { translate as t } from '@nextcloud/l10n'
import Vue, { defineAsyncComponent } from 'vue'
// async to reduce bundle size
const TemplatePickerVue = defineAsyncComponent(() => import('../views/TemplatePicker.vue'))
let TemplatePicker: ComponentInstance & { open: (n: string, t: TemplateFile) => void } | null = null
const getTemplatePicker = async () => {
if (TemplatePicker === null) {
// Create document root
const mountingPoint = document.createElement('div')
mountingPoint.id = 'template-picker'
document.body.appendChild(mountingPoint)
// Init vue app
TemplatePicker = new Vue({
render: (h) => h(TemplatePickerVue, { ref: 'picker' }),
methods: { open(...args) { this.$refs.picker.open(...args) } },
el: mountingPoint,
})
}
return TemplatePicker
}
/**
* Register all new-file-menu entries for all template providers
*/
export function registerTemplateEntries() {
const templates = loadState<TemplateFile[]>('files', 'templates', [])
// Init template files menu
templates.forEach((provider, index) => {
addNewFileMenuEntry({
id: `template-new-${provider.app}-${index}`,
displayName: provider.label,
// TODO: migrate to inline svg
iconClass: provider.iconClass || 'icon-file',
enabled(context: Folder): boolean {
return (context.permissions & Permission.CREATE) !== 0
},
order: 11,
async handler(context: Folder, content: Node[]) {
const templatePicker = getTemplatePicker()
const name = await newNodeName(`${provider.label}${provider.extension}`, content, {
label: t('files', 'Filename'),
name: provider.label,
})
if (name !== null) {
// Create the file
const picker = await templatePicker
picker.open(name, provider)
}
},
} as Entry)
})
}

@ -0,0 +1,100 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Julius Härtl <jus@bitgrid.net>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { Entry, Folder, Node } from '@nextcloud/files'
import { getCurrentUser } from '@nextcloud/auth'
import { showError } from '@nextcloud/dialogs'
import { Permission, removeNewFileMenuEntry } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { join } from 'path'
import { newNodeName } from '../utils/newNodeDialog'
import PlusSvg from '@mdi/svg/svg/plus.svg?raw'
import axios from '@nextcloud/axios'
import logger from '../logger.js'
let templatesPath = loadState<string|false>('files', 'templates_path', false)
logger.debug('Initial templates folder', { templatesPath })
/**
* Init template folder
* @param directory Folder where to create the templates folder
* @param name Name to use or the templates folder
*/
const initTemplatesFolder = async function(directory: Folder, name: string) {
const templatePath = join(directory.path, name)
try {
logger.debug('Initializing the templates directory', { templatePath })
const { data } = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), {
templatePath,
copySystemTemplates: true,
})
// Go to template directory
window.OCP.Files.Router.goToRoute(
null, // use default route
{ view: 'files', fileid: undefined },
{ dir: templatePath },
)
logger.info('Created new templates folder', {
...data.ocs.data,
})
templatesPath = data.ocs.data.templates_path as string
} catch (error) {
logger.error('Unable to initialize the templates directory')
showError(t('files', 'Unable to initialize the templates directory'))
}
}
export const entry = {
id: 'template-picker',
displayName: t('files', 'Create new templates folder'),
iconSvgInline: PlusSvg,
order: 10,
enabled(context: Folder): boolean {
// Templates folder already initialized
if (templatesPath) {
return false
}
// Allow creation on your own folders only
if (context.owner !== getCurrentUser()?.uid) {
return false
}
return (context.permissions & Permission.CREATE) !== 0
},
async handler(context: Folder, content: Node[]) {
const name = await newNodeName(t('files', 'Templates'), content, { name: t('files', 'New template folder') })
if (name !== null) {
// Create the template folder
initTemplatesFolder(context, name)
// Remove the menu entry
removeNewFileMenuEntry('template-picker')
}
},
} as Entry

@ -0,0 +1,57 @@
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { Node } from '@nextcloud/files'
import { spawnDialog } from '@nextcloud/dialogs'
import NewNodeDialog from '../components/NewNodeDialog.vue'
interface ILabels {
/**
* Dialog heading, defaults to "New folder name"
*/
name?: string
/**
* Label for input box, defaults to "New folder"
*/
label?: string
}
/**
* Ask user for file or folder name
* @param defaultName Default name to use
* @param folderContent Nodes with in the current folder to check for unique name
* @param labels Labels to set on the dialog
* @return string if successfull otherwise null if aborted
*/
export function newNodeName(defaultName: string, folderContent: Node[], labels: ILabels = {}) {
const contentNames = folderContent.map((node: Node) => node.basename)
return new Promise<string|null>((resolve) => {
spawnDialog(NewNodeDialog, {
...labels,
defaultName,
otherNames: contentNames,
}, (folderName) => {
resolve(folderName as string|null)
})
})
}

@ -566,15 +566,20 @@ export default defineComponent({
/**
* Refreshes the current folder on update.
*
* @param {Node} node is the file/folder being updated.
* @param node is the file/folder being updated.
*/
onUpdatedNode(node) {
onUpdatedNode(node?: Node) {
if (node?.fileid === this.currentFolder?.fileid) {
this.fetchContent()
}
},
openSharingSidebar() {
if (!this.currentFolder) {
logger.debug('No current folder found for opening sharing sidebar')
return
}
if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
window.OCA.Files.Sidebar.setActiveTab('sharing')
}

Loading…
Cancel
Save