Skip to content

Commit 5a33d9f

Browse files
committed
refactor(move-file-mutation): move business logic to custom hook and add validation to the destination field
1 parent 7d63441 commit 5a33d9f

File tree

3 files changed

+174
-64
lines changed

3 files changed

+174
-64
lines changed

src/components/modals/FormikModalWrapper.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ export const FormikModalWrapper = <Values extends FormikValues>({
5656
}: FormikConfig<Values> & FormikModalWrapperProps) => {
5757
return (
5858
<Formik {...formikProps}>
59-
{({ submitForm, isSubmitting, isValid, ...rest }) => (
59+
{({ submitForm, isSubmitting, isValid, resetForm, ...rest }) => (
6060
<Form style={{ display: "inline" }}>
6161
<ModalWrapper
6262
closeText={closeText}
63-
DialogProps={DialogProps}
63+
DialogProps={{ onTransitionExited: () => resetForm(), ...DialogProps }}
6464
id={id}
6565
open={open}
6666
submitDisabled={isSubmitting || !isValid}
@@ -70,7 +70,7 @@ export const FormikModalWrapper = <Values extends FormikValues>({
7070
onSubmit={() => void submitForm()}
7171
>
7272
{typeof children === "function"
73-
? children({ isValid, submitForm, isSubmitting, ...rest })
73+
? children({ isValid, isSubmitting, submitForm, resetForm, ...rest })
7474
: children}
7575
</ModalWrapper>
7676
</Form>

src/features/ProjectTable/buttons/RenameButton.tsx

Lines changed: 19 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,32 @@
11
import { useState } from "react";
22

3-
import {
4-
getGetFilesQueryKey,
5-
useMoveFileInProject,
6-
useMovePath,
7-
} from "@squonk/data-manager-client/file-and-path";
8-
import { getGetProjectQueryKey } from "@squonk/data-manager-client/project";
9-
103
import DriveFileRenameOutlineRoundedIcon from "@mui/icons-material/DriveFileRenameOutlineRounded";
114
import { Box, IconButton, type IconButtonProps, Tooltip } from "@mui/material";
12-
import { useQueryClient } from "@tanstack/react-query";
135
import { Field } from "formik";
146
import { TextField } from "formik-mui";
7+
import * as yup from "yup";
158

169
import { FormikModalWrapper } from "../../../components/modals/FormikModalWrapper";
17-
import { useEnqueueError } from "../../../hooks/useEnqueueStackError";
10+
import {
11+
PATH_PATTERN,
12+
type ProjectObject,
13+
useMoveProjectObject,
14+
} from "../../../hooks/api/useMoveProjectObject";
1815

1916
// removed type from IconButtonProps as it's also defined there
2017
export interface RenameButtonProps extends Omit<IconButtonProps, "type"> {
2118
projectId: string;
22-
type: "directory" | "file";
19+
type: ProjectObject;
2320
path: string;
2421
}
2522

2623
export const RenameButton = ({ projectId, type, path, ...buttonProps }: RenameButtonProps) => {
27-
const { mutateAsync: moveFile } = useMoveFileInProject();
28-
const { mutateAsync: moveDirectory } = useMovePath();
29-
3024
const [open, setOpen] = useState(false);
3125
const initialValues = {
3226
dstPath: path,
3327
};
3428

35-
const { enqueueSnackbar, enqueueError } = useEnqueueError();
36-
37-
const queryClient = useQueryClient();
38-
39-
const handleSubmit = async ({ dstPath }: typeof initialValues) => {
40-
try {
41-
// E.g. for a type="file": /path/to/file.txt -> /new/path/to/file.txt
42-
const fileName = path.split("/").pop() as string; // file.txt
43-
const srcPath = "/" + path.split("/").slice(0, -1).join("/"); // /path/to
44-
const dstPathStem = "/" + dstPath.split("/").slice(0, -1).join("/"); // /new/path/to
45-
const dstFile = dstPath.split("/").pop() as string; // file.txt
46-
47-
await (type === "directory"
48-
? moveDirectory({
49-
params: {
50-
project_id: projectId,
51-
dst_path: "/" + dstPath,
52-
src_path: "/" + path,
53-
},
54-
})
55-
: moveFile({
56-
params: {
57-
project_id: projectId,
58-
file: fileName,
59-
src_path: srcPath,
60-
dst_path: dstPathStem,
61-
dst_file: dstFile,
62-
},
63-
}));
64-
65-
enqueueSnackbar({ message: "Renamed and/or moved", variant: "success" });
66-
67-
await Promise.allSettled([
68-
queryClient.invalidateQueries({ queryKey: getGetProjectQueryKey(projectId) }),
69-
queryClient.invalidateQueries({
70-
queryKey: getGetFilesQueryKey({ project_id: projectId, path: srcPath }),
71-
}),
72-
queryClient.invalidateQueries({
73-
queryKey: getGetFilesQueryKey({ project_id: projectId, path: dstPathStem }),
74-
}),
75-
]);
76-
77-
setOpen(false);
78-
} catch (error) {
79-
enqueueError(error);
80-
}
81-
};
29+
const { handleMove } = useMoveProjectObject(projectId, path, type, () => setOpen(false));
8230

8331
return (
8432
<>
@@ -93,8 +41,18 @@ export const RenameButton = ({ projectId, type, path, ...buttonProps }: RenameBu
9341
open={open}
9442
submitText="Rename / Move"
9543
title="Rename / Move"
44+
validationSchema={yup.object().shape({
45+
dstPath: yup
46+
.string()
47+
.matches(PATH_PATTERN, "The path is invalid. It should not start or end with a slash.")
48+
.trim()
49+
.required("A destination path is required")
50+
.max(255),
51+
})}
9652
onClose={() => setOpen(false)}
97-
onSubmit={handleSubmit}
53+
onSubmit={({ dstPath }, { resetForm }) => {
54+
handleMove(dstPath, { onSettled: () => resetForm() });
55+
}}
9856
>
9957
<Box p={1}>
10058
<Field
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {
2+
getGetFilesQueryKey,
3+
useMoveFileInProject,
4+
useMovePath,
5+
} from "@squonk/data-manager-client/file-and-path";
6+
import { getGetProjectQueryKey } from "@squonk/data-manager-client/project";
7+
8+
import { useQueryClient } from "@tanstack/react-query";
9+
10+
import { useEnqueueError } from "../useEnqueueStackError";
11+
12+
export type ProjectObject = "directory" | "file";
13+
14+
/**
15+
* Valid pattern for a path that will be accepted by the API.
16+
* A leading `/` is prepended to the path before being sent
17+
*/
18+
export const PATH_PATTERN = /^[A-Za-z0-9-_.]+(\/[A-Za-z0-9-_.]+)*$/u;
19+
20+
const getPathStem = (path: string) => "/" + path.split("/").slice(0, -1).join("/");
21+
22+
// type OnSettledType<T extends {}> = NonNullable<NonNullable<T>["mutation"]>["onSettled"];
23+
24+
/**
25+
* This hook is used to move project files or directories from one path to another.
26+
*
27+
* It will handle the appropriate mutation and query cache invalidations.
28+
*
29+
* When a file is renamed and/or moved, we must invalidate the query cache for
30+
* - getGetProjectQueryKey for the current project ID
31+
* - getGetFilesQueryKey for the source path
32+
* - getGetFilesQueryKey for the destination path
33+
*
34+
* When a directory is moved, we must invalidate the query cache for
35+
* - getGetProjectQueryKey for the current project ID
36+
* - getGetFilesQueryKey for the source path
37+
* - getGetFilesQueryKey for the destination path
38+
*
39+
* @param projectId - ID of the project the file or directory belongs to
40+
* @param srcPath - Path of the file or directory to be moved
41+
* @param type - Type of the object to be moved
42+
* @param onSettled - Callback function to be called when the mutation is settled.
43+
* Useful to reset a form or close a modal.
44+
*/
45+
export const useMoveProjectObject = (
46+
projectId: string,
47+
srcPath: string,
48+
type: ProjectObject,
49+
onSettled?: () => void,
50+
) => {
51+
const { enqueueError, enqueueSnackbar } = useEnqueueError();
52+
53+
const queryClient = useQueryClient();
54+
55+
// Invalidate the queries for the project and the source and destination paths
56+
// Used in each of the mutation calls
57+
const invalidateQueries = (srcPath: string, dstPath?: string) => {
58+
const promises = [
59+
queryClient.invalidateQueries({ queryKey: getGetProjectQueryKey(projectId) }),
60+
queryClient.invalidateQueries({
61+
queryKey: getGetFilesQueryKey({ project_id: projectId, path: getPathStem(srcPath) }),
62+
}),
63+
];
64+
if (dstPath) {
65+
promises.push(
66+
queryClient.invalidateQueries({
67+
queryKey: getGetFilesQueryKey({ project_id: projectId, path: getPathStem(dstPath) }),
68+
}),
69+
);
70+
}
71+
return Promise.allSettled(promises);
72+
};
73+
74+
// Mutation Hooks
75+
const {
76+
mutate: moveFile,
77+
isPending: filePending,
78+
error: fileError,
79+
} = useMoveFileInProject({
80+
mutation: {
81+
onSettled: async (_data, _error, { params: { src_path, dst_file } }) => {
82+
await invalidateQueries(src_path as string, dst_file as string);
83+
onSettled && onSettled();
84+
},
85+
onSuccess: () => {
86+
enqueueSnackbar({ message: "File renamed and/or moved", variant: "success" });
87+
},
88+
onError: (error) => enqueueError(error),
89+
},
90+
});
91+
92+
const {
93+
mutate: moveDirectory,
94+
isPending: directoryPending,
95+
error: directoryError,
96+
} = useMovePath({
97+
mutation: {
98+
onSettled: async (_data, _error, { params: { src_path } }) => {
99+
await invalidateQueries(src_path as string);
100+
onSettled && onSettled();
101+
},
102+
onSuccess: () => {
103+
enqueueSnackbar({ message: "Directory renamed and/or moved", variant: "success" });
104+
},
105+
onError: (error) => enqueueError(error),
106+
},
107+
});
108+
109+
// Handlers
110+
const handleFileMove = (dstPath: string, mutationOptions?: Parameters<typeof moveFile>[1]) => {
111+
// compute the query parameters for the requests
112+
// E.g. for a type="file": /path/to/file.txt -> /new/path/to/file.txt
113+
const fileName = srcPath.split("/").pop() as string; // file.txt
114+
const srcPathStem = getPathStem(srcPath); // /path/to
115+
const dstPathStem = getPathStem(dstPath); // /new/path/to
116+
const dstFile = dstPath.trim().split("/").pop() as string; // file.txt
117+
118+
moveFile(
119+
{
120+
params: {
121+
project_id: projectId,
122+
file: fileName,
123+
src_path: srcPathStem,
124+
dst_path: dstPathStem,
125+
dst_file: dstFile,
126+
},
127+
},
128+
mutationOptions,
129+
);
130+
};
131+
132+
const handleDirectoryMove = (
133+
dstPath: string,
134+
mutationOptions?: Parameters<typeof moveDirectory>[1],
135+
) => {
136+
moveDirectory(
137+
{
138+
params: {
139+
project_id: projectId,
140+
dst_path: "/" + dstPath,
141+
src_path: "/" + srcPath,
142+
},
143+
},
144+
mutationOptions,
145+
);
146+
};
147+
148+
if (type === "directory") {
149+
return { handleMove: handleDirectoryMove, isPending: directoryPending, error: directoryError };
150+
}
151+
return { handleMove: handleFileMove, isPending: filePending, error: fileError };
152+
};

0 commit comments

Comments
 (0)