Can attach file to movement
This commit is contained in:
		@@ -42,6 +42,7 @@ pub struct ServerConstraints {
 | 
			
		||||
    pub token_max_inactivity: LenConstraints,
 | 
			
		||||
    pub account_name: LenConstraints,
 | 
			
		||||
    pub movement_label: LenConstraints,
 | 
			
		||||
    pub file_allowed_types: &'static [&'static str],
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Default for ServerConstraints {
 | 
			
		||||
@@ -52,6 +53,13 @@ impl Default for ServerConstraints {
 | 
			
		||||
            token_max_inactivity: LenConstraints::new(3600, 3600 * 24 * 365),
 | 
			
		||||
            account_name: LenConstraints::not_empty(50),
 | 
			
		||||
            movement_label: LenConstraints::not_empty(200),
 | 
			
		||||
            file_allowed_types: &[
 | 
			
		||||
                "image/jpeg",
 | 
			
		||||
                "image/png",
 | 
			
		||||
                "image/webp",
 | 
			
		||||
                "image/gif",
 | 
			
		||||
                "application/pdf",
 | 
			
		||||
            ],
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										28
									
								
								moneymgr_web/src/api/FileApi.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								moneymgr_web/src/api/FileApi.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
import { APIClient } from "./ApiClient";
 | 
			
		||||
 | 
			
		||||
export interface UploadedFile {
 | 
			
		||||
  id: number;
 | 
			
		||||
  time_create: number;
 | 
			
		||||
  mime_type: string;
 | 
			
		||||
  sha512: string;
 | 
			
		||||
  file_size: number;
 | 
			
		||||
  file_name: string;
 | 
			
		||||
  user_id: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class FileApi {
 | 
			
		||||
  /**
 | 
			
		||||
   * Upload a new file
 | 
			
		||||
   */
 | 
			
		||||
  static async UploadFile(file: File): Promise<UploadedFile> {
 | 
			
		||||
    const fd = new FormData();
 | 
			
		||||
    fd.append("file", file);
 | 
			
		||||
    return (
 | 
			
		||||
      await APIClient.exec({
 | 
			
		||||
        method: "POST",
 | 
			
		||||
        uri: "/file",
 | 
			
		||||
        formData: fd,
 | 
			
		||||
      })
 | 
			
		||||
    ).data;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -19,6 +19,7 @@ export interface ServerConstraints {
 | 
			
		||||
  token_max_inactivity: LenConstraint;
 | 
			
		||||
  account_name: LenConstraint;
 | 
			
		||||
  movement_label: LenConstraint;
 | 
			
		||||
  file_allowed_types: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface LenConstraint {
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,8 @@ import { DateWidget } from "../widgets/DateWidget";
 | 
			
		||||
import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer";
 | 
			
		||||
import { NewMovementWidget } from "../widgets/NewMovementWidget";
 | 
			
		||||
import { NotFoundRoute } from "./NotFound";
 | 
			
		||||
import { UploadFileButton } from "../widgets/forms/UploadFileButton";
 | 
			
		||||
import { UploadedFile } from "../api/FileApi";
 | 
			
		||||
 | 
			
		||||
export function AccountRoute(): React.ReactElement {
 | 
			
		||||
  const loadingMessage = useLoadingMessage();
 | 
			
		||||
@@ -105,6 +107,21 @@ function MovementsTable(p: {
 | 
			
		||||
  const [rowSelectionModel, setRowSelectionModel] =
 | 
			
		||||
    React.useState<GridRowSelectionModel>([]);
 | 
			
		||||
 | 
			
		||||
  // Set uploaded file
 | 
			
		||||
  const setUploadedFile = async (
 | 
			
		||||
    m: Movement,
 | 
			
		||||
    file: UploadedFile | undefined
 | 
			
		||||
  ) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await MovementApi.Update({ ...m, file_id: file?.id ?? undefined });
 | 
			
		||||
 | 
			
		||||
      p.needReload(false);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      console.error("Failed to attach file to movement!", e);
 | 
			
		||||
      alert("Failed to attach uploaded file to movement!");
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Change account of movement
 | 
			
		||||
  const handleMoveClick = async (movement: Movement) => {
 | 
			
		||||
    const targetAccount = await chooseAccount(
 | 
			
		||||
@@ -283,7 +300,19 @@ function MovementsTable(p: {
 | 
			
		||||
    {
 | 
			
		||||
      field: "file",
 | 
			
		||||
      headerName: "File",
 | 
			
		||||
      // TODO
 | 
			
		||||
      editable: false,
 | 
			
		||||
      width: 150,
 | 
			
		||||
      renderCell: (params) => {
 | 
			
		||||
        if (!params.row.file_id)
 | 
			
		||||
          return (
 | 
			
		||||
            <UploadFileButton
 | 
			
		||||
              label="Attach file"
 | 
			
		||||
              tooltip="Attach a file to this movement"
 | 
			
		||||
              onUploaded={(f) => setUploadedFile(params.row, f)}
 | 
			
		||||
            />
 | 
			
		||||
          );
 | 
			
		||||
        else return <>got file</>;
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      field: "actions",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										97
									
								
								moneymgr_web/src/widgets/forms/UploadFileButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								moneymgr_web/src/widgets/forms/UploadFileButton.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,97 @@
 | 
			
		||||
import { Button, IconButton, Tooltip, Typography } from "@mui/material";
 | 
			
		||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
 | 
			
		||||
import { ServerApi } from "../../api/ServerApi";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { FileApi, UploadedFile } from "../../api/FileApi";
 | 
			
		||||
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
 | 
			
		||||
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
 | 
			
		||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
 | 
			
		||||
 | 
			
		||||
// https://medium.com/@dprincecoder/creating-a-drag-and-drop-file-upload-component-in-react-a-step-by-step-guide-4d93b6cc21e0
 | 
			
		||||
 | 
			
		||||
export function UploadFileButton(p: {
 | 
			
		||||
  onUploaded: (file: UploadedFile) => void;
 | 
			
		||||
  label: string;
 | 
			
		||||
  tooltip: string;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  const alert = useAlert();
 | 
			
		||||
  const loadingMessage = useLoadingMessage();
 | 
			
		||||
  const snackbar = useSnackbar();
 | 
			
		||||
 | 
			
		||||
  const fileInput = React.useRef<HTMLInputElement>(null);
 | 
			
		||||
 | 
			
		||||
  const [dragActive, setDragActive] = React.useState(false);
 | 
			
		||||
 | 
			
		||||
  const forceFileSelect = () => fileInput.current?.click();
 | 
			
		||||
 | 
			
		||||
  const dragEnter = () => {
 | 
			
		||||
    setDragActive(true);
 | 
			
		||||
  };
 | 
			
		||||
  const dragLeave = () => {
 | 
			
		||||
    setDragActive(false);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleUpload = async (file: File[]) => {
 | 
			
		||||
    if (file.length < 1) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      loadingMessage.show("Uploading file...");
 | 
			
		||||
 | 
			
		||||
      const result = await FileApi.UploadFile(file[0]);
 | 
			
		||||
 | 
			
		||||
      snackbar("The file was successfully uploaded!");
 | 
			
		||||
 | 
			
		||||
      p.onUploaded(result);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      console.error("Failed to upload file!", e);
 | 
			
		||||
      alert(`Failed to upload file! ${e}`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      loadingMessage.hide();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleDrop = (ev: React.DragEvent) => {
 | 
			
		||||
    ev.preventDefault();
 | 
			
		||||
    handleUpload([...ev.dataTransfer.files]);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handlefileChange = (ev: React.ChangeEvent) => {
 | 
			
		||||
    ev.preventDefault();
 | 
			
		||||
    if ((fileInput.current?.files?.length ?? 0) > 0) {
 | 
			
		||||
      handleUpload([...fileInput.current?.files!]);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Tooltip
 | 
			
		||||
      title={p.tooltip}
 | 
			
		||||
      onDrop={handleDrop}
 | 
			
		||||
      onDragOver={(event) => event.preventDefault()}
 | 
			
		||||
      onDragEnter={dragEnter}
 | 
			
		||||
      onDragLeave={dragLeave}
 | 
			
		||||
    >
 | 
			
		||||
      <Button
 | 
			
		||||
        size="small"
 | 
			
		||||
        startIcon={<UploadFileIcon fontSize="small" />}
 | 
			
		||||
        variant={dragActive ? "outlined" : "text"}
 | 
			
		||||
        onClick={forceFileSelect}
 | 
			
		||||
      >
 | 
			
		||||
        <input
 | 
			
		||||
          ref={fileInput}
 | 
			
		||||
          type="file"
 | 
			
		||||
          accept={ServerApi.Config.constraints.file_allowed_types.join(",")}
 | 
			
		||||
          style={{
 | 
			
		||||
            border: "0",
 | 
			
		||||
            height: "1px",
 | 
			
		||||
            width: "1px",
 | 
			
		||||
            padding: "0px",
 | 
			
		||||
            position: "absolute",
 | 
			
		||||
            clipPath: "inset(50%)",
 | 
			
		||||
          }}
 | 
			
		||||
          onChange={handlefileChange}
 | 
			
		||||
        />
 | 
			
		||||
        <Typography variant="caption">{p.label}</Typography>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </Tooltip>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user