fix(files): route drops through the selected target
This commit is contained in:
@@ -13,6 +13,9 @@
|
|||||||
},
|
},
|
||||||
"./styles/file-manager.css": "./src/styles/file-manager.css"
|
"./styles/file-manager.css": "./src/styles/file-manager.css"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"vite": "^6.0.6",
|
"vite": "^6.0.6",
|
||||||
|
|||||||
35
webui/scripts/test-file-drop-target-structure.mjs
Normal file
35
webui/scripts/test-file-drop-target-structure.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const source = readFileSync(new URL("../src/features/files/FilesPage.tsx", import.meta.url), "utf8");
|
||||||
|
const normalized = source.replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
function assertIncludes(fragment, message) {
|
||||||
|
if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertIncludes(
|
||||||
|
"const target = options.target ?? currentActionTarget();",
|
||||||
|
"uploads must prefer the explicit target supplied by the initiating interaction"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"const targetSpace = target ? findSpace(target.spaceId) : null;",
|
||||||
|
"the upload owner must be resolved from the selected target"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"owner_type: targetSpace.owner_type, owner_id: targetSpace.owner_id, path: target.folderPath,",
|
||||||
|
"the upload request must use the selected target owner and folder"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"async function uploadExternalFilesToTarget(fileList: FileList | File[], target: FileActionTarget) { await handleFilesUpload(fileList, { target }); }",
|
||||||
|
"external drops must pass their concrete target without asynchronous dialog-state mutation"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"await uploadExternalFilesToTarget(event.dataTransfer.files, target);",
|
||||||
|
"drop handling must forward the target on which the files were dropped"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"onFiles={(files) => handleFilesUpload(files, { target: activeDialogTarget || undefined })}",
|
||||||
|
"the dialog picker/drop zone must snapshot its selected target for upload"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Files upload target routing structure is intact.");
|
||||||
@@ -439,6 +439,16 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
return activeDialogTarget;
|
return activeDialogTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uploadRejectedReason(target: FileActionTarget | null): string {
|
||||||
|
if (busy || uploadActive) return "Another file operation is already running. Wait until it finishes before dropping files.";
|
||||||
|
if (!canUpload) return "You do not have permission to upload files here.";
|
||||||
|
if (!target) return "Choose a destination folder before dropping files.";
|
||||||
|
const targetSpace = findSpace(target.spaceId);
|
||||||
|
if (!targetSpace) return "The selected upload destination is no longer available.";
|
||||||
|
if (isConnectorSpace(targetSpace)) return "Files cannot be uploaded into connector spaces from this view.";
|
||||||
|
return "The dropped item cannot be uploaded here.";
|
||||||
|
}
|
||||||
|
|
||||||
function openDialog(kind: DialogKind, target: FileActionTarget | null = null) {
|
function openDialog(kind: DialogKind, target: FileActionTarget | null = null) {
|
||||||
if ((kind === "upload" || kind === "connector-sync") && !canUpload) return;
|
if ((kind === "upload" || kind === "connector-sync") && !canUpload) return;
|
||||||
if (kind === "connector-space" && !canOrganize) return;
|
if (kind === "connector-space" && !canOrganize) return;
|
||||||
@@ -615,10 +625,12 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
|
|
||||||
|
|
||||||
async function handleFilesUpload(fileList: FileList | File[], options: {conflictStrategy?: ConflictStrategy;conflictResolutions?: ConflictResolution[];bypassConflictDialog?: boolean;target?: FileActionTarget;} = {}) {
|
async function handleFilesUpload(fileList: FileList | File[], options: {conflictStrategy?: ConflictStrategy;conflictResolutions?: ConflictResolution[];bypassConflictDialog?: boolean;target?: FileActionTarget;} = {}) {
|
||||||
if (!canUpload) return;
|
|
||||||
const target = options.target ?? currentActionTarget();
|
const target = options.target ?? currentActionTarget();
|
||||||
const targetSpace = target ? findSpace(target.spaceId) : null;
|
const targetSpace = target ? findSpace(target.spaceId) : null;
|
||||||
if (!target || !targetSpace || isConnectorSpace(targetSpace)) return;
|
if (busy || uploadActive || !canUpload || !target || !targetSpace || isConnectorSpace(targetSpace)) {
|
||||||
|
setError(uploadRejectedReason(target));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const selected = Array.from(fileList);
|
const selected = Array.from(fileList);
|
||||||
if (selected.length === 0) return;
|
if (selected.length === 0) return;
|
||||||
if (!options.bypassConflictDialog && !unpackZip) {
|
if (!options.bypassConflictDialog && !unpackZip) {
|
||||||
@@ -678,13 +690,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function uploadExternalFilesToTarget(fileList: FileList | File[], target: FileActionTarget) {
|
async function uploadExternalFilesToTarget(fileList: FileList | File[], target: FileActionTarget) {
|
||||||
const previousTarget = dialogTarget;
|
await handleFilesUpload(fileList, { target });
|
||||||
setDialogTarget(target);
|
|
||||||
try {
|
|
||||||
await handleFilesUpload(fileList);
|
|
||||||
} finally {
|
|
||||||
setDialogTarget(previousTarget);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openConnectorSyncDialog(target: FileActionTarget | null = toolbarTarget()) {
|
async function openConnectorSyncDialog(target: FileActionTarget | null = toolbarTarget()) {
|
||||||
@@ -1339,8 +1345,20 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
setDropTargetKey(reason || noop ? "" : dropTargetId(target));
|
setDropTargetKey(reason || noop ? "" : dropTargetId(target));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.dataTransfer.dropEffect = canUpload ? "copy" : "none";
|
event.dataTransfer.dropEffect = canUpload && !busy && !uploadActive ? "copy" : "none";
|
||||||
setDropTargetKey(canUpload ? dropTargetId(target) : "");
|
setDropTargetKey(canUpload && !busy && !uploadActive ? dropTargetId(target) : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCurrentFolderDragOver(event: ReactDragEvent<HTMLElement>) {
|
||||||
|
const target = toolbarTarget();
|
||||||
|
if (!target) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.dataTransfer.dropEffect = "none";
|
||||||
|
setDropTargetKey("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleDropTargetDragOver(event, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDropOnTarget(event: ReactDragEvent<HTMLElement>, target: FileActionTarget) {
|
async function handleDropOnTarget(event: ReactDragEvent<HTMLElement>, target: FileActionTarget) {
|
||||||
@@ -1348,7 +1366,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setDragActive(false);
|
setDragActive(false);
|
||||||
setDropTargetKey("");
|
setDropTargetKey("");
|
||||||
if (isConnectorSpace(findSpace(target.spaceId))) return;
|
const hasDroppedFiles = event.dataTransfer.files.length > 0;
|
||||||
|
if (isConnectorSpace(findSpace(target.spaceId))) {
|
||||||
|
if (hasDroppedFiles) setError(uploadRejectedReason(target));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isInternalDrag(event)) {
|
if (isInternalDrag(event)) {
|
||||||
if (!canOrganize) return;
|
if (!canOrganize) return;
|
||||||
const state = internalDrag ?? parseDragState(event.dataTransfer.getData(INTERNAL_DRAG_TYPE));
|
const state = internalDrag ?? parseDragState(event.dataTransfer.getData(INTERNAL_DRAG_TYPE));
|
||||||
@@ -1361,9 +1383,27 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
await runTransfer(event.ctrlKey ? "copy" : "move", state.sourceSpaceId, { fileIds: new Set(state.fileIds), folderPaths: new Set(state.folderPaths) }, target);
|
await runTransfer(event.ctrlKey ? "copy" : "move", state.sourceSpaceId, { fileIds: new Set(state.fileIds), folderPaths: new Set(state.folderPaths) }, target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (canUpload && event.dataTransfer.files.length > 0) {
|
if (hasDroppedFiles) {
|
||||||
|
if (!canUpload || busy || uploadActive) {
|
||||||
|
setError(uploadRejectedReason(target));
|
||||||
|
return;
|
||||||
|
}
|
||||||
await uploadExternalFilesToTarget(event.dataTransfer.files, target);
|
await uploadExternalFilesToTarget(event.dataTransfer.files, target);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setError("Drop one or more files from your computer, or drag files and folders from this file space.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDropOnCurrentFolder(event: ReactDragEvent<HTMLElement>) {
|
||||||
|
const target = toolbarTarget();
|
||||||
|
if (!target) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setDropTargetKey("");
|
||||||
|
setError(uploadRejectedReason(null));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleDropOnTarget(event, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearDropState() {
|
function clearDropState() {
|
||||||
@@ -1940,6 +1980,9 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentFolderDropTarget = toolbarTarget();
|
||||||
|
const currentFolderDropActive = currentFolderDropTarget ? dropTargetKey === dropTargetId(currentFolderDropTarget) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen">
|
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen">
|
||||||
{error &&
|
{error &&
|
||||||
@@ -2051,9 +2094,12 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
renderConnectorSpaceContent() :
|
renderConnectorSpaceContent() :
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="file-list-drop-target"
|
className={`file-list-drop-target ${currentFolderDropActive ? "is-drop-target" : ""}`}
|
||||||
ref={fileListViewportRef}
|
ref={fileListViewportRef}
|
||||||
onScroll={(event) => setFileListScrollTop(event.currentTarget.scrollTop)}
|
onScroll={(event) => setFileListScrollTop(event.currentTarget.scrollTop)}
|
||||||
|
onDragOver={handleCurrentFolderDragOver}
|
||||||
|
onDragLeave={clearDropState}
|
||||||
|
onDrop={(event) => void handleDropOnCurrentFolder(event)}
|
||||||
onContextMenu={(event) => openContextMenu(event, "empty")}
|
onContextMenu={(event) => openContextMenu(event, "empty")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget) applySelectionKeys(new Set<EntrySelectionKey>());
|
if (event.target === event.currentTarget) applySelectionKeys(new Set<EntrySelectionKey>());
|
||||||
@@ -2179,13 +2225,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
{dialog === "upload" &&
|
{dialog === "upload" &&
|
||||||
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
|
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
|
||||||
<FileDropZone
|
<FileDropZone
|
||||||
disabled={busy || !activeDialogSpace || !canUpload}
|
disabled={busy || !activeDialogTarget || !activeDialogSpace || isConnectorSpace(activeDialogSpace) || !canUpload}
|
||||||
busy={uploadActive}
|
busy={uploadActive}
|
||||||
progress={visibleUploadProgress}
|
progress={visibleUploadProgress}
|
||||||
busyLabel={uploadBusyLabel}
|
busyLabel={uploadBusyLabel}
|
||||||
progressLabel={uploadProgressLabel}
|
progressLabel={uploadProgressLabel}
|
||||||
note={`Files are uploaded into ${activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}.`}
|
note={`Files are uploaded into ${activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}.`}
|
||||||
onFiles={(files) => handleFilesUpload(files)} />
|
onRejectedDrop={(reason) => setError(reason === "disabled" ? uploadRejectedReason(activeDialogTarget) : "The browser did not provide readable file data for this drop. Use the file picker, or drag local files from a file manager that exposes file contents to the browser.")}
|
||||||
|
onFiles={(files) => handleFilesUpload(files, { target: activeDialogTarget || undefined })} />
|
||||||
|
|
||||||
<ToggleSwitch label="i18n:govoplan-files.unpack_zip_uploads.256fead4" checked={unpackZip} onChange={setUnpackZip} disabled={busy} />
|
<ToggleSwitch label="i18n:govoplan-files.unpack_zip_uploads.256fead4" checked={unpackZip} onChange={setUnpackZip} disabled={busy} />
|
||||||
<div className="field-block">
|
<div className="field-block">
|
||||||
|
|||||||
Reference in New Issue
Block a user