diff --git a/components/FileBrowser.tsx b/components/FileBrowser.tsx deleted file mode 100644 index c249977..0000000 --- a/components/FileBrowser.tsx +++ /dev/null @@ -1,469 +0,0 @@ -import React, { useCallback, useContext, useEffect, useState } from "react"; -import { - Table, - TableContainer, - Tr, - Th, - Td, - Thead, - Code, - Icon, - HStack, - Text, - Checkbox, - Button, - Alert, - AlertIcon, - Box, - AlertTitle, - AlertDescription, - Spinner, - Center, - Tbody, - IconButton, - List, - ListItem, - ButtonGroup, - Flex, - Tooltip, -} from "@chakra-ui/react"; -import { ChevronRightIcon } from "@chakra-ui/icons"; -import { - FolderIcon, - DocumentIcon, - ArrowUpOnSquareIcon, - ArrowPathIcon, - ArrowUturnUpIcon, -} from "@heroicons/react/24/outline"; -import { transfer } from "@globus/sdk/cjs"; - -import type { - DirectoryListingError, - FileDocument, -} from "@globus/sdk/cjs/lib/services/transfer/service/file-operations"; - -import { useGlobusAuth } from "./globus-auth-context/useGlobusAuth"; -import { TransferSettingsDispatchContext } from "./transfer-settings-context/Context"; -import { readable } from "@/utils/globus"; - -export default function FileBrowser({ - variant, - collection, - path, -}: { - variant: "source" | "destination"; - collection: string; - path?: string; -}) { - const auth = useGlobusAuth(); - const dispatch = useContext(TransferSettingsDispatchContext); - - const isSource = variant === "source"; - - const [browserPath, setBrowserPath] = useState(path); - const [isLoading, setIsLoading] = useState(false); - const [endpoint, setEndpoint] = useState | null>(null); - const [lsResponse, setLsResponse] = useState | null>( - null, - ); - const [items, setItems] = useState([]); - const [error, setError] = useState( - null, - ); - - useEffect(() => { - async function fetchEndpoint() { - if (!auth.isAuthenticated) { - return; - } - const response = await transfer.endpoint.get(collection, { - headers: { - Authorization: `Bearer ${auth.authorization?.tokens.transfer?.access_token}`, - }, - }); - const data = await response.json(); - if (!response.ok) { - setError("code" in data ? data : null); - return; - } - setEndpoint(data); - } - fetchEndpoint(); - }, [auth, collection]); - - const fetchItems = useCallback(async () => { - if (!auth.isAuthenticated) { - return; - } - setIsLoading(true); - const isSource = variant === "source"; - if (isSource) { - dispatch({ - type: "RESET_ITEMS", - }); - } - const response = await transfer.fileOperations.ls(collection, { - headers: { - Authorization: `Bearer ${auth.authorization?.tokens.transfer?.access_token}`, - }, - query: { - path: browserPath ?? undefined, - }, - }); - const data = await response.json(); - setIsLoading(false); - setLsResponse(data); - if (!response.ok) { - setError("code" in data ? data : null); - return; - } - setItems("DATA" in data ? data.DATA : []); - const transferPath = "absolute_path" in data ? data.absolute_path : null; - dispatch({ - type: isSource ? "SET_SOURCE_PATH" : "SET_DESTINATION_PATH", - payload: transferPath, - }); - }, [auth, browserPath, collection, dispatch, variant]); - - useEffect(() => { - fetchItems(); - }, [fetchItems]); - - return ( - <> - {isLoading && ( - -
- -
-
- )} - - {error && } - - {!isLoading && !error && lsResponse && ( - <> - - - {lsResponse.absolute_path} - - - - - - - - - - - - {isSource && - - - {isSource && - - - {items.map((item, i) => ( - - {isSource && ( - - )} - - - - {isSource && ( - - )} - - ))} - -
} - NameLast ModifiedSize} -
- { - if (e.target.checked) { - dispatch({ - type: "ADD_ITEM", - payload: item.name, - }); - } else { - dispatch({ - type: "REMOVE_ITEM", - payload: item.name, - }); - } - }} - /> - - - - {item.type === "dir" ? ( - - ) : ( - {item.name} - )} - - - {item.last_modified ? ( - - - {new Intl.DateTimeFormat("en-US", { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(item.last_modified))} - - - ) : ( - - )} - - {item.size ? ( - - - {item.size && readable(item.size)} - - - ) : ( - - )} - - {endpoint && - endpoint.https_server && - item.type === "file" && ( - } - /> - )} -
-
- - )} - - ); -} - -const FileBrowserError = ({ - error, -}: { - error: DirectoryListingError | unknown; -}) => { - const auth = useGlobusAuth(); - - const isWellFormedError = ( - error: unknown, - ): error is DirectoryListingError => { - return ( - typeof error === "object" && - error !== null && - "code" in error && - "message" in error - ); - }; - - const isDirectoryListingError = isWellFormedError(error); - - if (isDirectoryListingError && error.code === "ConsentRequired") { - return ( - - - - - {error.message} - - - - You'll need to grant access to this resource in order to proceed. - - - - - - ); - } - - if ( - isDirectoryListingError && - error.code === "PermissionDenied" && - "authorization_parameters" in error - ) { - /* eslint-disable camelcase */ - const { - session_message, - session_required_identities, - session_required_mfa, - session_required_single_domain, - } = error.authorization_parameters as Record; - - return ( - - - - - {error.message} - - - {session_message && {session_message}} - - {session_required_mfa && ( - Requires Multi-Factor Authentication - )} - {session_required_identities && ( - - Required Identities:{" "} - {session_required_identities.join(", ")} - - )} - {session_required_single_domain && - session_required_single_domain?.length && ( - - Required Single Domain:{" "} - {session_required_single_domain} - - )} - - {/* */} - - {JSON.stringify(error, null, 2)} - - - - - ); - /* eslint-enable camelcase */ - } - - if (isDirectoryListingError && error.code === "AuthenticationFailed") { - return ( - - - - - {error.message} - - - - Please try logging in again to refresh your credentials. - - - - - - ); - } - - if (isDirectoryListingError) { - return ( - - - - - {error.message} - - - {JSON.stringify(error, null, 2)} - - - - ); - } - - return ( - - - Unknown Error - - - {JSON.stringify(error, null, 2)} - - - - ); -}; - -const FileEntryIcon = ({ entry }: { entry: FileDocument }) => { - if (entry.type === "dir") { - return ; - } - return ; -}; diff --git a/components/file-browser/Context.tsx b/components/file-browser/Context.tsx new file mode 100644 index 0000000..8bb268d --- /dev/null +++ b/components/file-browser/Context.tsx @@ -0,0 +1,5 @@ +import { createContext } from "react"; +import { Dispatcher, initialState } from "./reducer"; + +export const FileBrowserContext = createContext(initialState); +export const FileBrowserDispatchContext = createContext(() => {}); diff --git a/components/file-browser/FileBrowser.tsx b/components/file-browser/FileBrowser.tsx new file mode 100644 index 0000000..5011b3a --- /dev/null +++ b/components/file-browser/FileBrowser.tsx @@ -0,0 +1,321 @@ +import React, { + useCallback, + useContext, + useEffect, + useReducer, + useState, +} from "react"; + +import { + Table, + TableContainer, + Tr, + Th, + Td, + Thead, + Code, + Icon, + HStack, + Text, + Checkbox, + Button, + Box, + Spinner, + Center, + Tbody, + IconButton, + ButtonGroup, + Flex, + Tooltip, + Spacer, +} from "@chakra-ui/react"; +import { ChevronRightIcon } from "@chakra-ui/icons"; +import { + ArrowUpOnSquareIcon, + ArrowPathIcon, + ArrowUturnUpIcon, +} from "@heroicons/react/24/outline"; +import { transfer } from "@globus/sdk/cjs"; + +import type { + DirectoryListingError, + FileDocument, +} from "@globus/sdk/cjs/lib/services/transfer/service/file-operations"; + +import { useGlobusAuth } from "../globus-auth-context/useGlobusAuth"; +import { TransferSettingsDispatchContext } from "../transfer-settings-context/Context"; +import { readable } from "@/utils/globus"; + +import FileBrowserViewMenu from "./FileBrowserViewMenu"; +import FileBrowserError from "./FileBrowserError"; +import FileEntryIcon from "./FileEntryIcon"; + +import { FileBrowserContext, FileBrowserDispatchContext } from "./Context"; +import fileBrowserReducer, { initialState } from "./reducer"; + +export default function FileBrowser({ + variant, + collection, + path, +}: { + variant: "source" | "destination"; + collection: string; + path?: string; +}) { + const auth = useGlobusAuth(); + + const [fileBrowser, fileBrowserDispatch] = useReducer( + fileBrowserReducer, + initialState, + ); + + const transferSettingsDispatch = useContext(TransferSettingsDispatchContext); + + const isSource = variant === "source"; + + const [browserPath, setBrowserPath] = useState(path); + const [isLoading, setIsLoading] = useState(false); + const [endpoint, setEndpoint] = useState | null>(null); + const [lsResponse, setLsResponse] = useState | null>( + null, + ); + const [items, setItems] = useState([]); + const [error, setError] = useState( + null, + ); + + useEffect(() => { + async function fetchEndpoint() { + if (!auth.isAuthenticated) { + return; + } + const response = await transfer.endpoint.get(collection, { + headers: { + Authorization: `Bearer ${auth.authorization?.tokens.transfer?.access_token}`, + }, + }); + const data = await response.json(); + if (!response.ok) { + setError("code" in data ? data : null); + return; + } + setEndpoint(data); + } + fetchEndpoint(); + }, [auth, collection]); + + const fetchItems = useCallback(async () => { + if (!auth.isAuthenticated) { + return; + } + setIsLoading(true); + const isSource = variant === "source"; + if (isSource) { + transferSettingsDispatch({ + type: "RESET_ITEMS", + }); + } + console.log(fileBrowser.view.show_hidden); + const response = await transfer.fileOperations.ls(collection, { + headers: { + Authorization: `Bearer ${auth.authorization?.tokens.transfer?.access_token}`, + }, + query: { + path: browserPath ?? undefined, + show_hidden: fileBrowser.view.show_hidden ? "true" : "false", + }, + }); + const data = await response.json(); + setIsLoading(false); + setLsResponse(data); + if (!response.ok) { + setError("code" in data ? data : null); + return; + } + setItems("DATA" in data ? data.DATA : []); + const transferPath = "absolute_path" in data ? data.absolute_path : null; + transferSettingsDispatch({ + type: isSource ? "SET_SOURCE_PATH" : "SET_DESTINATION_PATH", + payload: transferPath, + }); + }, [ + auth, + browserPath, + collection, + transferSettingsDispatch, + variant, + fileBrowser.view.show_hidden, + ]); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + return ( + <> + + + {isLoading && ( + +
+ +
+
+ )} + + {error ? : null} + + {!isLoading && !error && lsResponse && ( + <> + + + {lsResponse.absolute_path} + + + + + + + + + + + + + + {isSource && + {fileBrowser.view.columns.includes("last_modified") && ( + + )} + {fileBrowser.view.columns.includes("size") && ( + + )} + {isSource && + + + {items.map((item, i) => ( + + {isSource && ( + + )} + + {fileBrowser.view.columns.includes("last_modified") && ( + + )} + {fileBrowser.view.columns.includes("size") && ( + + )} + {isSource && ( + + )} + + ))} + +
} + NameLast ModifiedSize} +
+ { + if (e.target.checked) { + transferSettingsDispatch({ + type: "ADD_ITEM", + payload: item, + }); + } else { + transferSettingsDispatch({ + type: "REMOVE_ITEM", + payload: item, + }); + } + }} + /> + + + + {item.type === "dir" ? ( + + ) : ( + {item.name} + )} + + + {item.last_modified ? ( + + + {new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(item.last_modified))} + + + ) : ( + + )} + + {item.size ? ( + + + {item.size && readable(item.size)} + + + ) : ( + + )} + + {endpoint && + endpoint.https_server && + item.type === "file" && ( + } + /> + )} +
+
+ + )} +
+
+ + ); +} diff --git a/components/file-browser/FileBrowserError.tsx b/components/file-browser/FileBrowserError.tsx new file mode 100644 index 0000000..2acba2e --- /dev/null +++ b/components/file-browser/FileBrowserError.tsx @@ -0,0 +1,195 @@ +import React from "react"; +import { + Code, + HStack, + Text, + Button, + Alert, + AlertIcon, + Box, + AlertTitle, + AlertDescription, + List, + ListItem, +} from "@chakra-ui/react"; + +import { useGlobusAuth } from "../globus-auth-context/useGlobusAuth"; + +import type { DirectoryListingError } from "@globus/sdk/cjs/lib/services/transfer/service/file-operations"; + +export default function FileBrowserError({ + error, +}: { + error: DirectoryListingError | unknown; +}) { + const auth = useGlobusAuth(); + + const isWellFormedError = ( + error: unknown, + ): error is DirectoryListingError => { + return ( + typeof error === "object" && + error !== null && + "code" in error && + "message" in error + ); + }; + + const isDirectoryListingError = isWellFormedError(error); + + if (isDirectoryListingError && error.code === "ConsentRequired") { + return ( + + + + + {error.message} + + + + You'll need to grant access to this resource in order to proceed. + + + + + + ); + } + + if ( + isDirectoryListingError && + error.code === "PermissionDenied" && + "authorization_parameters" in error + ) { + /* eslint-disable camelcase */ + const { + session_message, + session_required_identities, + session_required_mfa, + session_required_single_domain, + } = error.authorization_parameters as Record; + + return ( + + + + + {error.message} + + + {session_message && {session_message}} + + {session_required_mfa && ( + Requires Multi-Factor Authentication + )} + {session_required_identities && ( + + Required Identities:{" "} + {session_required_identities.join(", ")} + + )} + {session_required_single_domain && + session_required_single_domain?.length && ( + + Required Single Domain:{" "} + {session_required_single_domain} + + )} + + {/* */} + + {JSON.stringify(error, null, 2)} + + + + + ); + /* eslint-enable camelcase */ + } + + if (isDirectoryListingError && error.code === "AuthenticationFailed") { + return ( + + + + + {error.message} + + + + Please try logging in again to refresh your credentials. + + + + + + ); + } + + if (isDirectoryListingError) { + return ( + + + + + {error.message} + + + {JSON.stringify(error, null, 2)} + + + + ); + } + + return ( + + + Unknown Error + + + {JSON.stringify(error, null, 2)} + + + + ); +} diff --git a/components/file-browser/FileBrowserViewMenu.tsx b/components/file-browser/FileBrowserViewMenu.tsx new file mode 100644 index 0000000..2010441 --- /dev/null +++ b/components/file-browser/FileBrowserViewMenu.tsx @@ -0,0 +1,55 @@ +import React, { useContext } from "react"; + +import { + Menu, + MenuButton, + Button, + MenuList, + MenuOptionGroup, + MenuItemOption, + MenuDivider, +} from "@chakra-ui/react"; + +import { FileBrowserContext, FileBrowserDispatchContext } from "./Context"; + +export default function FileBrowserViewMenu() { + const state = useContext(FileBrowserContext); + const dispatch = useContext(FileBrowserDispatchContext); + return ( + + + View Settings + + + { + console.log(value); + dispatch({ + type: "SET_VIEW_SHOW_HIDDEN", + payload: value.includes("show_hidden"), + }); + }} + > + Show Hidden Items + + + { + dispatch({ type: "SET_VIEW_COLUMNS", payload: value }); + }} + > + + Name + + Last Modified + Size + + + + ); +} diff --git a/components/file-browser/FileEntryIcon.tsx b/components/file-browser/FileEntryIcon.tsx new file mode 100644 index 0000000..8cc78da --- /dev/null +++ b/components/file-browser/FileEntryIcon.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { Icon } from "@chakra-ui/react"; +import { FolderIcon, DocumentIcon } from "@heroicons/react/24/outline"; + +import type { FileDocument } from "@globus/sdk/cjs/lib/services/transfer/service/file-operations"; + +export default function FileEntryIcon({ entry }: { entry: FileDocument }) { + if (entry.type === "dir") { + return ; + } + return ; +} diff --git a/components/file-browser/reducer.tsx b/components/file-browser/reducer.tsx new file mode 100644 index 0000000..6a658f4 --- /dev/null +++ b/components/file-browser/reducer.tsx @@ -0,0 +1,45 @@ +type Action = { type: string; payload?: any }; +type State = { + view: { + columns: string[]; + show_hidden: boolean; + }; +}; + +export const initialState: State = { + view: { + show_hidden: false, + columns: ["name", "last_modified", "size"], + }, +}; + +export type Dispatcher = (action: Action) => void; + +export default function fileBrowserReducer( + state: State, + action: Action, +): State { + switch (action.type) { + case "SET_VIEW_COLUMNS": { + return { + ...state, + view: { + ...state.view, + columns: action.payload, + }, + }; + } + case "SET_VIEW_SHOW_HIDDEN": { + return { + ...state, + view: { + ...state.view, + show_hidden: action.payload, + }, + }; + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} diff --git a/components/transfer-settings-context/reducer.ts b/components/transfer-settings-context/reducer.ts index 50ce479..7e45d14 100644 --- a/components/transfer-settings-context/reducer.ts +++ b/components/transfer-settings-context/reducer.ts @@ -1,10 +1,12 @@ +import { FileDocument } from "@globus/sdk/cjs/lib/services/transfer/service/file-operations"; + type Action = { type: string; payload?: any }; type State = { source: Record | null; source_path: string | null; destination: Record | null; destination_path: string | null; - items: string[]; + items: FileDocument[]; }; export const initialState: State = { diff --git a/pages/index.tsx b/pages/index.tsx index a26f1ff..0b8b50b 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -28,7 +28,7 @@ import { PlayCircleIcon, XCircleIcon } from "@heroicons/react/24/outline"; import { transfer } from "@globus/sdk/cjs"; -import FileBrowser from "@/components/FileBrowser"; +import FileBrowser from "@/components/file-browser/FileBrowser"; import { useGlobusAuth } from "@/components/globus-auth-context/useGlobusAuth"; import { CollectionSearch } from "@/components/CollectionSearch"; @@ -85,11 +85,14 @@ export default function Home() { label: `Transfer from ${STATIC.content.title}`, source_endpoint: transferSettings.source.id, destination_endpoint: transferSettings.destination.id, - DATA: transferSettings.items.map((item) => ({ - DATA_TYPE: "transfer_item", - source_path: `${transferSettings.source_path}${item}`, - destination_path: `${transferSettings.destination_path}`, - })), + DATA: transferSettings.items.map((item) => { + return { + DATA_TYPE: "transfer_item", + source_path: `${transferSettings.source_path}${item.name}`, + destination_path: `${transferSettings.destination_path}${item.name}`, + recursive: item.type === "dir", + }; + }), }, headers: { ...getTransferHeaders(),