diff --git a/VueApp/src/CAHFS/router/index.ts b/VueApp/src/CAHFS/router/index.ts index 1ee7424d1..bf695865a 100644 --- a/VueApp/src/CAHFS/router/index.ts +++ b/VueApp/src/CAHFS/router/index.ts @@ -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" diff --git a/VueApp/src/CMS/components/FileFormDialog.vue b/VueApp/src/CMS/components/FileFormDialog.vue index adaa42864..68bba5c8b 100644 --- a/VueApp/src/CMS/components/FileFormDialog.vue +++ b/VueApp/src/CMS/components/FileFormDialog.vue @@ -1,9 +1,11 @@ @@ -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<{ @@ -173,6 +235,7 @@ 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" @@ -180,6 +243,7 @@ const acceptedExtensions = const isEdit = computed(() => props.file !== null) const formRef = ref() const saving = ref(false) +const formError = ref("") type FileForm = { upload: File | null @@ -188,11 +252,18 @@ 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, @@ -200,13 +271,34 @@ const emptyForm = (): FileForm => ({ oldUrl: "", allowPublicAccess: false, encrypt: false, - makeUnique: false, permissions: [], people: [], }) const form = ref(emptyForm()) +const { setInitialState, confirmClose } = useUnsavedChanges(form) + +const conflict = ref(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]) => { @@ -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 { @@ -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) @@ -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 { + 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 } diff --git a/VueApp/src/CMS/components/LeftNavMenuDialog.vue b/VueApp/src/CMS/components/LeftNavMenuDialog.vue new file mode 100644 index 000000000..f7e434a18 --- /dev/null +++ b/VueApp/src/CMS/components/LeftNavMenuDialog.vue @@ -0,0 +1,149 @@ + + + diff --git a/VueApp/src/CMS/components/LeftNavMenuSettingsFields.vue b/VueApp/src/CMS/components/LeftNavMenuSettingsFields.vue new file mode 100644 index 000000000..8f1440197 --- /dev/null +++ b/VueApp/src/CMS/components/LeftNavMenuSettingsFields.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/VueApp/src/CMS/components/RecentActivity.vue b/VueApp/src/CMS/components/RecentActivity.vue new file mode 100644 index 000000000..596166bc1 --- /dev/null +++ b/VueApp/src/CMS/components/RecentActivity.vue @@ -0,0 +1,145 @@ + + + diff --git a/VueApp/src/CMS/components/StatusIcon.vue b/VueApp/src/CMS/components/StatusIcon.vue new file mode 100644 index 000000000..6237c192a --- /dev/null +++ b/VueApp/src/CMS/components/StatusIcon.vue @@ -0,0 +1,18 @@ + + + diff --git a/VueApp/src/CMS/pages/BulkEncrypt.vue b/VueApp/src/CMS/pages/BulkEncrypt.vue index 5224965a2..5e339061d 100644 --- a/VueApp/src/CMS/pages/BulkEncrypt.vue +++ b/VueApp/src/CMS/pages/BulkEncrypt.vue @@ -1,18 +1,10 @@