Skip to content
Closed
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
2 changes: 1 addition & 1 deletion VueApp/src/CAHFS/router/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createSpaRouter } from "@/shared/createSpaRouter"
import { createSpaRouter } from "@/shared/create-spa-router"
import { routes } from "./routes"
import { useRequireLogin } from "@/composables/RequireLogin"
import { checkHasOnePermission } from "@/composables/CheckPagePermission"
Expand Down
224 changes: 197 additions & 27 deletions VueApp/src/CMS/components/FileFormDialog.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
<template>
<q-dialog
:model-value="modelValue"
persistent
aria-labelledby="file-dialog-title"
@update:model-value="emit('update:modelValue', $event)"
@hide="resetForm"
@keydown.escape="handleClose"
>
<q-card style="width: 600px; max-width: 95vw">
<q-card-section class="row items-center q-pb-none">
<div
id="file-dialog-title"
class="text-h6"
>
{{ isEdit ? "Edit File" : "Upload File" }}
{{ isEdit ? "Edit File" : "Add File" }}
</div>
<q-space />
<q-btn
Expand All @@ -20,14 +22,15 @@
round
dense
aria-label="Close dialog"
v-close-popup
@click="handleClose"
/>
</q-card-section>

<q-form
ref="formRef"
greedy
@submit.prevent="save"
@validation-error="onValidationError"
>
<q-card-section class="q-gutter-y-sm">
<div
Expand Down Expand Up @@ -121,12 +124,14 @@
v-model="form.encrypt"
label="Encrypt file"
/>
<q-checkbox
v-if="!isEdit"
v-model="form.makeUnique"
label="Rename automatically if name exists"
/>
</div>

<StatusBanner
v-if="formError"
type="error"
>
{{ formError }}
</StatusBanner>
</q-card-section>

<q-card-actions align="right">
Expand All @@ -135,19 +140,74 @@
label="Cancel"
dense
no-caps
v-close-popup
@click="handleClose"
/>
<q-btn
type="submit"
:label="isEdit ? 'Save Changes' : 'Upload'"
color="primary"
dense
no-caps
class="q-pr-md"
:loading="saving"
/>
</q-card-actions>
</q-form>

<q-dialog
v-model="showConflict"
persistent
aria-labelledby="conflict-dialog-title"
>
<q-card style="width: 480px; max-width: 95vw">
<q-card-section class="row items-center q-pb-none">
<div
id="conflict-dialog-title"
class="text-h6"
>
File name already exists
</div>
<q-space />
<q-btn
icon="close"
flat
round
dense
aria-label="Close dialog"
@click="showConflict = false"
/>
</q-card-section>
<q-card-section>
<p class="text-body2">
<strong>{{ form.upload?.name }}</strong> already exists in <strong>{{ form.folder }}</strong
>{{ conflictDetail }}. Choose how to continue:
</p>
<q-option-group
v-model="conflictChoice"
:options="conflictOptions"
/>
<q-input
v-if="conflictChoice === 'rename'"
v-model="renameTo"
dense
outlined
label="New file name"
class="q-mt-sm"
:rules="[(v: string) => !!v?.trim() || 'Enter a file name']"
hide-bottom-space
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
color="primary"
dense
no-caps
:label="conflictChoice === 'rename' ? 'Upload with new name' : 'Overwrite'"
:loading="saving"
@click="resolveConflict"
/>
</q-card-actions>
</q-card>
</q-dialog>
</q-card>
</q-dialog>
</template>
Expand All @@ -156,8 +216,10 @@
import { computed, inject, ref, watch } from "vue"
import { useQuasar } from "quasar"
import { useFetch } from "@/composables/ViperFetch"
import { useUnsavedChanges } from "@/composables/use-unsaved-changes"
import PermissionSelector from "@/CMS/components/PermissionSelector.vue"
import PersonSelector from "@/CMS/components/PersonSelector.vue"
import StatusBanner from "@/components/StatusBanner.vue"
import type { CmsFile, CmsFilePerson } from "@/CMS/types/"

const props = defineProps<{
Expand All @@ -173,13 +235,15 @@ const emit = defineEmits<{

const apiURL = inject("apiURL") + "cms/files/"
const $q = useQuasar()
const { get, postForm, putForm, createUrlSearchParams } = useFetch()

const acceptedExtensions =
".pdf,.docx,.doc,.xls,.xlsx,.csv,.ppt,.pptx,.pptm,.txt,.html,.gif,.png,.jpg,.jpeg,.tiff,.mp3,.wav,.mp4,.webm,.oft,.eps,.zip,.7z,.dmg,.exe"

const isEdit = computed(() => props.file !== null)
const formRef = ref()
const saving = ref(false)
const formError = ref("")

type FileForm = {
upload: File | null
Expand All @@ -188,25 +252,53 @@ type FileForm = {
oldUrl: string
allowPublicAccess: boolean
encrypt: boolean
makeUnique: boolean
permissions: string[]
people: CmsFilePerson[]
}

type NameCheck = {
inUse: boolean
suggestedName: string
existingFileGuid: string | null
existingFriendlyName: string | null
existingDeleted: boolean
}

const emptyForm = (): FileForm => ({
upload: null,
folder: null,
description: "",
oldUrl: "",
allowPublicAccess: false,
encrypt: false,
makeUnique: false,
permissions: [],
people: [],
})

const form = ref<FileForm>(emptyForm())

const { setInitialState, confirmClose } = useUnsavedChanges(form)

const conflict = ref<NameCheck | null>(null)
const showConflict = ref(false)
const conflictChoice = ref<"rename" | "overwrite">("rename")
const renameTo = ref("")

const conflictOptions = computed(() => [
{ label: "Upload with a new name", value: "rename" },
{
label: conflict.value?.existingFileGuid
? "Overwrite the existing file (replaces its content and details)"
: "Overwrite the existing file on disk",
value: "overwrite",
},
])

const conflictDetail = computed(() => {
if (!conflict.value?.existingFriendlyName) return ""
return ` (${conflict.value.existingFriendlyName}${conflict.value.existingDeleted ? ", deleted" : ""})`
})

watch(
() => [props.modelValue, props.file] as const,
([open]) => {
Expand All @@ -219,21 +311,39 @@ watch(
oldUrl: props.file.oldUrl ?? "",
allowPublicAccess: props.file.allowPublicAccess,
encrypt: props.file.encrypted,
makeUnique: false,
permissions: [...props.file.permissions],
people: props.file.people.map((p) => ({ ...p })),
}
} else {
form.value = emptyForm()
}
formError.value = ""
setInitialState()
},
)

function resetForm() {
form.value = emptyForm()
conflict.value = null
showConflict.value = false
formError.value = ""
formRef.value?.resetValidation()
}

async function handleClose() {
if (await confirmClose()) {
emit("update:modelValue", false)
}
}

// The q-form focuses the first invalid field on a failed submit; this surfaces a matching
// message next to the action buttons so the failure is obvious.
function onValidationError() {
formError.value = isEdit.value
? "Please complete the required fields before saving."
: "Please choose a file and complete the required fields before uploading."
}

async function copyUrl() {
if (!props.file) return
try {
Expand All @@ -244,14 +354,25 @@ async function copyUrl() {
}
}

function buildFormData(): FormData {
type SubmitOptions = {
fileName?: string
overwrite?: boolean
overwriteGuid?: string
}

function buildFormData(opts: SubmitOptions = {}): FormData {
const data = new FormData()
if (form.value.upload) {
data.append("file", form.value.upload)
}
if (!isEdit.value) {
if (!isEdit.value && !opts.overwriteGuid) {
data.append("folder", form.value.folder ?? "")
data.append("makeUnique", form.value.makeUnique.toString())
if (opts.fileName) {
data.append("fileName", opts.fileName)
}
if (opts.overwrite) {
data.append("overwrite", "true")
}
}
data.append("description", form.value.description)
data.append("oldUrl", form.value.oldUrl)
Expand All @@ -267,26 +388,75 @@ function buildFormData(): FormData {
}

async function save() {
const valid = await formRef.value?.validate()
if (!valid) return
if (saving.value) return
formError.value = ""

// New uploads check the destination name first; a conflict prompts for rename/overwrite.
if (!isEdit.value && form.value.upload && form.value.folder) {
saving.value = true
const params = createUrlSearchParams({ folder: form.value.folder, fileName: form.value.upload.name })
const check = await get(apiURL + "check-name?" + params)
saving.value = false
if (check.success && check.result.inUse) {
conflict.value = check.result
conflictChoice.value = "rename"
renameTo.value = check.result.suggestedName
showConflict.value = true
return
}
}
await submitSave()
}

async function resolveConflict() {
if (saving.value) return
// Keep the conflict dialog open until the upload succeeds, so its buttons show the
// loading state and a failure leaves the user here instead of stranded on the form.
if (conflictChoice.value === "rename") {
const name = renameTo.value.trim()
if (!name) return
if (await submitSave({ fileName: name })) showConflict.value = false
return
}
const opts: SubmitOptions = conflict.value?.existingFileGuid
? { overwriteGuid: conflict.value.existingFileGuid }
: { overwrite: true }
if (await submitSave(opts)) showConflict.value = false
}

async function submitSave(opts: SubmitOptions = {}): Promise<boolean> {
if (saving.value) return false
saving.value = true
const { postForm, putForm } = useFetch()
const res = isEdit.value
? await putForm(apiURL + props.file!.fileGuid, buildFormData())
: await postForm(apiURL, buildFormData())
let res
if (isEdit.value) {
res = await putForm(apiURL + props.file!.fileGuid, buildFormData())
} else if (opts.overwriteGuid) {
// Overwriting a managed file replaces the existing record's content and details.
res = await putForm(apiURL + opts.overwriteGuid, buildFormData(opts))
} else {
res = await postForm(apiURL, buildFormData(opts))
}
saving.value = false

if (!res.success) {
$q.notify({
type: "negative",
message: res.errors?.[0] ?? `Failed to ${isEdit.value ? "save" : "upload"} file`,
})
return
const message = res.errors?.[0] ?? `Failed to ${isEdit.value ? "save" : "upload"} file`
// During conflict resolution the user is in the conflict sub-dialog, so a toast is the
// only place they'd see the error; otherwise surface it on the main form banner.
if (showConflict.value) {
$q.notify({ type: "negative", message })
} else {
formError.value = message
}
return false
}

$q.notify({ type: "positive", message: isEdit.value ? "File updated" : "File uploaded" })
const overwrote = opts.overwrite || opts.overwriteGuid
$q.notify({
type: "positive",
message: isEdit.value ? "File updated" : overwrote ? "File overwritten" : "File uploaded",
})
emit("saved", res.result)
emit("update:modelValue", false)
return true
}
</script>
Loading