Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ describe('OcSwitch', () => {
expect(wrapper.emitted('update:checked')[0][0]).toEqual(true)
})

it('is not a submit button, so it does not hijack implicit form submission', () => {
const wrapper = shallowMount(Switch, {
props: defaultProps
})

expect(wrapper.find('[data-testid="oc-switch-btn"]').attributes('type')).toEqual('button')
})

it('does not toggle when disabled', async () => {
const wrapper = shallowMount(Switch, {
props: { ...defaultProps, disabled: true }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
</span>
<button
data-testid="oc-switch-btn"
type="button"
class="oc-switch-btn block relative border border-role-outline rounded-3xl w-8 before:size-3 h-4.5 disabled:cursor-default disabled:opacity-40"
:class="{ 'cursor-pointer': !disabled }"
role="switch"
Expand Down
229 changes: 229 additions & 0 deletions packages/web-app-files/src/components/Modals/CreateFolderModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<template>
<form autocomplete="off" @submit.prevent="onPrimaryAction">
<template v-if="step === 'name'">
<oc-text-input
id="create-folder-input"
v-model="folderName"
:label="$gettext('Folder name')"
required-mark
:error-message="errorMessage"
:fix-message-line="true"
@update:model-value="nameTouched = true"
/>
<div v-if="canEncrypt" data-testid="create-folder-encrypt">
<oc-switch
v-model:checked="encrypt"
:label="$gettext('End-to-end encrypt this folder')"
class="inline-flex flex-row-reverse [&>span:first-child]:flex-row-reverse [&>span:first-child]:ml-2"
>
<oc-icon
:name="encrypt ? 'lock-2' : 'lock-unlock'"
fill-type="line"
size-class="size-5"
/>
</oc-switch>
<p
class="mt-1 mb-0 ml-12 text-sm text-role-on-surface-variant"
v-text="
$gettext(
'Extra secure: unreadable without an extra password. Lose it and the files are lost too. Collaboration features are limited.'
)
"
/>
</div>
</template>
<component
:is="vaultCreation.setupComponent"
v-else
ref="setupComponent"
:vault-name="serverName"
@update:valid="setupValid = $event"
/>

<div class="flex justify-end items-center mt-4">
<div class="oc-modal-body-actions-grid">
<oc-button
v-if="step === 'setup'"
class="oc-modal-body-actions-cancel ml-2"
@click="step = 'name'"
>
{{ $gettext('Back') }}
</oc-button>
<oc-button
class="oc-modal-body-actions-confirm ml-2"
appearance="filled"
submit="submit"
:disabled="primaryDisabled"
>
{{ encrypt && step === 'name' ? $gettext('Continue') : $gettext('Create') }}
</oc-button>
</div>
</div>
</form>
</template>

<script setup lang="ts">
import { computed, ref, unref, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import { join } from 'path'
import { storeToRefs } from 'pinia'
import type { Resource } from '@opencloud-eu/web-client'
import {
resolveFileNameDuplicate,
useIsResourceNameValid,
useResourcesStore,
withExtension,
withoutExtension
} from '@opencloud-eu/web-pkg'
import type { FolderVaultCreation, FolderVaultFinalize, Modal } from '@opencloud-eu/web-pkg'

const {
modal,
vaultCreation = undefined,
callbackFn
} = defineProps<{
modal: Modal
/**
* Creation bits of the folder-vault extension that would claim the new folder:
* its setup UI and the folder extension it marks vault roots with. Absent when
* no extension can create vaults, hence hide the encryption switch.
*/
vaultCreation?: FolderVaultCreation
callbackFn: (
folderName: string,
options: { encrypt: boolean; finalizeVault?: FolderVaultFinalize }
) => Promise<void>
}>()

const emit = defineEmits<{
(e: 'confirm'): void
}>()

const { $gettext } = useGettext()
const { isFileNameValid } = useIsResourceNameValid()
const { resources, currentFolder, areFileExtensionsShown } = storeToRefs(useResourcesStore())

const setupComponent = ref<{ finalize: FolderVaultFinalize }>()
const step = ref<'name' | 'setup'>('name')
const encrypt = ref(false)
const setupValid = ref(false)
// Once the user edits the name we stop re-suggesting: typing a name that is
// taken has to surface the error, not be silently renumbered.
const nameTouched = ref(false)

const baseName = $gettext('New folder')

// The vault marker is the scheme's, never ours - without a scheme there is
// nothing to encrypt with and these are no-ops.
function vaultName(name: string): string {
return vaultCreation ? withExtension(name, vaultCreation.folderExtension) : name
}

function plainName(name: string): string {
return vaultCreation ? withoutExtension(name, vaultCreation.folderExtension) : name
}

/**
* `baseName`, numbered up until it's free for the mode we're about to create in.
* Only names in the same namespace collide, so `New folder` and `New folder.vault`
* can happily coexist.
*/
function suggestName(encrypting: boolean): string {
const siblings = unref(resources)
const wanted = encrypting ? vaultName(baseName) : baseName
if (!siblings.some((r) => r.name === wanted)) {
return baseName
}
return plainName(
resolveFileNameDuplicate(wanted, encrypting ? vaultCreation.folderExtension : '', siblings)
)
}

function displayName(base: string, encrypting: boolean): string {
return encrypting && unref(areFileExtensionsShown) ? vaultName(base) : base
}

const folderName = ref(displayName(suggestName(false), false))

const canEncrypt = computed(() => !!vaultCreation)

// The name that actually lands on the server: a vault root always carries the
// scheme's marker, so that's what gets validated - toggling the switch can make
// a name valid or invalid on its own.
const serverName = computed(() =>
unref(encrypt) ? vaultName(unref(folderName)) : unref(folderName)
)

// Which names are taken depends on the mode, so switching it re-runs the
// suggestion. With file extensions visible the field also shows the vault
// marker; with them hidden it stays out of sight and is only added on create,
// the same way the new-file menu handles extensions.
watch(encrypt, (on) => {
const base = unref(nameTouched) ? plainName(unref(folderName)) : suggestName(on)
folderName.value = displayName(base, on)
})

function validate(name: string) {
const resource = {
path: join(unref(currentFolder).path, name),
name,
isFolder: true
} as Resource
return isFileNameValid(resource, name, unref(resources))
}

const errorMessage = computed(() => {
// Validate what the user typed first so errors point at their input rather
// than at a marker they never entered.
const typed = validate(unref(folderName))
if (!typed.isValid) {
return typed.error
}
if (unref(serverName) === unref(folderName)) {
return undefined
}
return validate(unref(serverName)).error
})

const inputValid = computed(() =>
unref(step) === 'setup' ? unref(setupValid) : !unref(errorMessage)
)

const primaryDisabled = computed(() => !unref(inputValid) || modal.isLoading)

// Going back unmounts the setup step, so its input is gone - drop the validity
// it reported with it.
watch(step, (value) => {
if (value === 'name') {
setupValid.value = false
}
})

function onPrimaryAction() {
if (unref(primaryDisabled)) {
return
}
if (unref(encrypt) && unref(step) === 'name') {
step.value = 'setup'
return
}
// Routed through the wrapper rather than calling onConfirm directly: that's
// what shows the loading state and closes the modal afterwards.
emit('confirm')
}

async function onConfirm() {
// Guards the confirm path the modal wrapper drives, which can't know about
// the second step. Checks `inputValid` rather than `primaryDisabled`: the
// wrapper flips `isLoading` on before it calls us.
if (!unref(inputValid) || (unref(encrypt) && unref(step) === 'name')) {
return Promise.reject()
}
await callbackFn(unref(folderName), {
encrypt: unref(encrypt),
finalizeVault: unref(setupComponent)?.finalize
})
}

defineExpose({ onConfirm })
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
useMessages,
useModals,
useResourcesStore,
useRouter,
useUserStore
} from '@opencloud-eu/web-pkg'

Expand All @@ -33,7 +32,6 @@ export const useFileActionsCreateNewFile = ({ space }: { space?: Ref<SpaceResour

const { openEditor } = useFileActions()
const clientService = useClientService()
const router = useRouter()

const resourcesStore = useResourcesStore()
const { resources, currentFolder, areFileExtensionsShown } = storeToRefs(resourcesStore)
Expand All @@ -48,36 +46,17 @@ export const useFileActionsCreateNewFile = ({ space }: { space?: Ref<SpaceResour
const openFile = (resource: Resource, appFileExtension: ApplicationFileExtension) => {
resourcesStore.upsertResource(resource)

// Folder-typed new-menu entries (e.g. vault from rclone-crypt, notebooks
// from notes) may not register an editor route - when there's nothing to
// open we navigate into the freshly-created folder instead so the user
// ends up inside it. Anything that *does* have a route (apps like notes)
// keeps using openEditor.
const targetSpace = unref(space)
const routeName = appFileExtension?.routeName || appFileExtension?.app
if (appFileExtension?.type === 'folder' && !router.hasRoute(routeName)) {
const driveAliasAndItem = targetSpace?.getDriveAliasAndItem(resource)
if (driveAliasAndItem) {
router.push({
name: 'files-spaces-generic',
params: { driveAliasAndItem },
query: resource.fileId ? { fileId: resource.fileId } : undefined
})
return
}
}

return openEditor(appFileExtension, targetSpace, resource)
return openEditor(appFileExtension, unref(space), resource)
}

const handler = (
fileActionOptions: FileActionOptions,
extension: string,
appFileExtension: ApplicationFileExtension
) => {
// Apps may override the default name shown in the create modal - e.g.
// rclone-crypt wants "New vault.vault" instead of "New file.vault".
// Fall back to the generic "New file" prefix when no override is set.
// Apps may override the default name shown in the create modal, e.g. to
// read "New notebook.ocnb" rather than "New file.ocnb". Fall back to the
// generic "New file" prefix when no override is set.
const baseName = appFileExtension.newFileMenu?.defaultName?.() ?? $gettext('New file')
let defaultName = `${baseName}.${extension}`

Expand Down Expand Up @@ -188,24 +167,8 @@ export const useFileActionsCreateNewFile = ({ space }: { space?: Ref<SpaceResour
icon: 'add',
handler: (args) => handler(args, appFileExtension.extension, appFileExtension),
label: () => $gettext(appFileExtension.newFileMenu.menuTitle()),
isVisible: () => {
if (!unref(currentFolder)?.canUpload({ user: userStore.user })) {
return false
}
// No vault inside a vault: creating a ".vault" folder inside an
// (already encrypted) vault just yields a confusingly named folder, not
// a real nested vault, so hide that entry there.
if (appFileExtension.extension === 'vault' && unref(currentFolder)?.isInVault) {
return false
}
return true
},
isDisabled: () => {
if (isExternalActionInVault) {
return true
}
return false
},
isVisible: () => unref(currentFolder)?.canUpload({ user: userStore.user }),
isDisabled: () => isExternalActionInVault,
disabledTooltip: () => {
if (isExternalActionInVault) {
return $gettext('This file type cannot be created inside vaults')
Expand Down
Loading