diff --git a/esp/src/src-react/components/AppPanel.tsx b/esp/src/src-react/components/AppPanel.tsx index 308b00ad22d..06fc3a95205 100644 --- a/esp/src/src-react/components/AppPanel.tsx +++ b/esp/src/src-react/components/AppPanel.tsx @@ -33,7 +33,7 @@ export const AppPanel: React.FunctionComponent = ({ const retVal = []; webLinks?.forEach(webLink => { webLink.Annotations.NamedValue.forEach(nv => { - retVal.push(); + retVal.push(); }); }); // Include HPCC Systems link when there are no other web links available diff --git a/esp/src/src-react/components/LandingZone.tsx b/esp/src/src-react/components/LandingZone.tsx index f6b0b79a2d8..904f3d62acc 100644 --- a/esp/src/src-react/components/LandingZone.tsx +++ b/esp/src/src-react/components/LandingZone.tsx @@ -1,20 +1,23 @@ import * as React from "react"; import { CommandBar, ContextualMenuItemType, ICommandBarItemProps, mergeStyleSets } from "@fluentui/react"; -import { useConst, useOnEvent } from "@fluentui/react-hooks"; -import * as domClass from "dojo/dom-class"; +import { TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent } from "@fluentui/react-components"; +import { useOnEvent, useConst } from "@fluentui/react-hooks"; +import { FileSpray as HPCCFileSpray, FileSprayService, TopologyService, WsTopology } from "@hpcc-js/comms"; import * as iframe from "dojo/request/iframe"; -import * as put from "put-selector/put"; -import { TpDropZoneQuery } from "src/WsTopology"; import * as FileSpray from "src/FileSpray"; import * as ESPRequest from "src/ESPRequest"; import * as Utility from "src/Utility"; +import { userKeyValStore } from "src/KeyValStore"; import nlsHPCC from "src/nlsHPCC"; +import { BranchIcon, FlatItem, TreeView } from "./controls/TreeView"; +import { useBuildInfo } from "../hooks/platform"; import { useConfirm } from "../hooks/confirm"; -import { useGrid } from "../hooks/grid"; +import { FluentGrid, useCopyButtons, useFluentStoreState, FluentColumns } from "./controls/Grid"; +import { DockPanel, DockPanelItem, ResetableDockPanel } from "../layouts/DockPanel"; import { HolyGrail } from "../layouts/HolyGrail"; -import { pushParams } from "../util/history"; +import { pushParams, pushUrl } from "../util/history"; import { ShortVerticalDivider } from "./Common"; -import { selector, tree } from "./DojoGrid"; +import { selector } from "./DojoGrid"; import { Fields } from "./forms/Fields"; import { Filter } from "./forms/Filter"; import { AddFileForm } from "./forms/landing-zone/AddFileForm"; @@ -25,25 +28,9 @@ import { JsonImportForm } from "./forms/landing-zone/JsonImportForm"; import { VariableImportForm } from "./forms/landing-zone/VariableImportForm"; import { XmlImportForm } from "./forms/landing-zone/XmlImportForm"; import { FileListForm } from "./forms/landing-zone/FileListForm"; -import { QueryRequest } from "src/store/Memory"; - -function formatQuery(targetDropzones, filter): QueryRequest { - const dropzones = targetDropzones.filter(row => row.Name === filter?.DropZoneName); - const machines = targetDropzones[0]?.TpMachines?.TpMachine?.filter(row => row.ConfigNetaddress === filter?.Server); - return { - id: "*", - filter: (filter?.DropZoneName && dropzones.length && machines.length) ? { - DropZoneName: filter.DropZoneName, - Server: filter.Server, - NameFilter: filter.NameFilter, - ECLWatchVisibleOnly: true, - __dropZone: { - ...targetDropzones.filter(row => row.Name === filter?.DropZoneName)[0], - machine: machines[0] - } - } : undefined - }; -} + +const topologyService = new TopologyService({ baseUrl: "" }); +const fsService = new FileSprayService({ baseUrl: "" }); const buttonStyles = mergeStyleSets({ labelOnly: { @@ -72,14 +59,16 @@ const emptyFilter: LandingZoneFilter = {}; interface LandingZoneProps { filter?: LandingZoneFilter; + path?: string; } -let dzExpanded = ""; - export const LandingZone: React.FunctionComponent = ({ - filter = emptyFilter + filter = emptyFilter, + path = "" }) => { + const [, { isContainer }] = useBuildInfo(); + const hasFilter = React.useMemo(() => Object.keys(filter).length > 0, [filter]); const [showFilter, setShowFilter] = React.useState(false); @@ -93,33 +82,157 @@ export const LandingZone: React.FunctionComponent = ({ const [showDropZone, setShowDropzone] = React.useState(false); const [uploadFiles, setUploadFiles] = React.useState([]); const [showFileUpload, setShowFileUpload] = React.useState(false); - const [targetDropzones, setTargetDropzones] = React.useState([]); + const [layout, setLayout] = React.useState(); + const [dockpanel, setDockpanel] = React.useState(); + const [dropzones, setDropzones] = React.useState([]); + const [selectedDropzone, setSelectedDropzone] = React.useState(); + + const [data, setData] = React.useState([]); + const { + selection, setSelection, + setTotal, + refreshTable } = useFluentStoreState({}); + + const [userAddedFiles, setUserAddedFiles] = React.useState([]); + const [lzPath, setLzPath] = React.useState(""); + const [pathSep, setPathSep] = React.useState("/"); + + const [treeItems, setTreeItems] = React.useState([]); + const [openItems, setOpenItems] = React.useState>([]); + + const store = useConst(() => userKeyValStore()); React.useEffect(() => { - TpDropZoneQuery({}).then(({ TpDropZoneQueryResponse }) => { - setTargetDropzones(TpDropZoneQueryResponse?.TpDropZones?.TpDropZone || []); + if (dockpanel) { + const t = window.setTimeout(() => { + // Should only happen once on startup --- + const _layout: any = dockpanel.layout(); + if (Array.isArray(_layout?.main?.sizes) && _layout.main.sizes.length === 2) { + _layout.main.sizes = [0.2, 0.8]; + dockpanel.layout(_layout).lazyRender(); + setLayout(_layout); + store?.set("LzLayout", JSON.stringify(_layout), true); + } + window.clearTimeout(t); + }, 0); + } + }, [dockpanel, store]); + + React.useEffect(() => { + store?.set("LzLayout", JSON.stringify(layout), true); + }, [layout, store]); + + React.useEffect(() => { + topologyService.TpDropZoneQuery({ ECLWatchVisibleOnly: true }).then(response => { + const dropzones = response?.TpDropZones?.TpDropZone || []; + if (dropzones[0]?.Path?.indexOf("\\") > -1) { + setPathSep("\\"); + } + setDropzones(dropzones); + if (dropzones.length) { + setSelectedDropzone(dropzones[0]); + } }); }, []); + const addUserFile = React.useCallback((file) => { + setData([...data, file]); + setUserAddedFiles([...userAddedFiles, file]); + }, [data, userAddedFiles]); + + const removeUserFile = React.useCallback((name) => { + setData(data.filter(file => file.name !== name)); + setUserAddedFiles(userAddedFiles.filter(file => file.name !== name)); + }, [data, userAddedFiles]); + + React.useEffect(() => { + if (!dropzones || !selectedDropzone?.Path) return; + + const _path = path ? path.replace(/::/g, "/") : selectedDropzone.Path; + setLzPath(_path); + + const paths = []; + const items = []; + + dropzones.forEach(dz => { + items.push({ + value: dz?.Name, + label: dz?.Name, + icon: BranchIcon.Dropzone, + data: { + DropZoneName: dz?.Name, + path: dz?.Path + } + }); + dz?.TpMachines.TpMachine.forEach(machine => { + items.push({ + value: machine.Directory, + parentValue: dz.Name, + label: machine.Name, + icon: BranchIcon.Network, + data: { + DropZoneName: dz.Name, + path: machine.Directory + } + }); + }); + }); + + const openItems = new Set([selectedDropzone.Name, selectedDropzone.Path]); + const requests = []; + + let pathParts = _path.split("/"); + let tempPath = pathParts.join("/"); + + while (tempPath !== selectedDropzone?.Path) { + paths.push(tempPath); + pathParts = pathParts.slice(0, -1); + tempPath = pathParts.join("/"); + } + paths.push(selectedDropzone.Path); + + paths.reverse().forEach(path => { + requests.push(fsService.FileList({ + DropZoneName: selectedDropzone.Name, + Path: path, + DirectoryOnly: true + })); + openItems.add(path); + }); + + Promise.all(requests).then(responses => { + responses.forEach(response => { + const files = response?.files?.PhysicalFileStruct?.sort((a, b) => { + if (a.name < b.name) return -1; + if (a.name > b.name) return 1; + return 0; + }) ?? []; + files?.forEach(file => { + const itemPath = file.Path + file.name; + if (items.filter(item => item.value === itemPath).length === 0) { + items.push({ + value: itemPath, + parentValue: file.Path.slice(0, -1), + label: file.name, + icon: file.isDir ? BranchIcon.Directory : BranchIcon.None, + data: { path: itemPath } + }); + } + if (itemPath.length <= _path) { + openItems.add(itemPath); + } + }); + }); + + setTreeItems(items); + setOpenItems(openItems); + }); + + }, [dropzones, path, selectedDropzone]); + // Grid --- - const store = useConst(() => FileSpray.CreateLandingZonesStore({})); - - const query = React.useMemo(() => { - return formatQuery(targetDropzones, filter); - }, [filter, targetDropzones]); - - const { Grid, selection, refreshTable, copyButtons } = useGrid({ - store, - query, - sort: { attribute: "modifiedtime", descending: true }, - filename: "landingZones", - getSelected: function () { - if (filter?.__dropZone) { - return this.inherited(arguments, [FileSpray.CreateLandingZonesFilterStore({ dropZone: filter.__dropZone })]); - } - return this.inherited(arguments, [FileSpray.CreateFileListStore({})]); - }, - columns: { + const columns = React.useMemo((): FluentColumns => { + return { col1: selector({ width: 27, disabled: function (item) { @@ -135,53 +248,47 @@ export const LandingZone: React.FunctionComponent = ({ }, selectorType: "checkbox" }), - displayName: tree({ + displayName: { label: nlsHPCC.Name, - sortable: false, - shouldExpand: function (row, level) { - if ((dzExpanded === "" || dzExpanded === row.data.DropZone?.Name) && level <= 1) { - dzExpanded = row.data.DropZone.Name; - return true; - } - return false; - }, formatter: function (_name, row) { - let img = ""; - let name = row.displayName; - if (row.isDir === undefined) { - img = Utility.getImageHTML("server.png"); - name += " [" + row.Path + "]"; - } else if (row.isMachine) { - img = Utility.getImageHTML("machine.png"); - } else if (row.isDir) { - img = Utility.getImageHTML("folder.png"); - } else { - img = Utility.getImageHTML("file.png"); - } - return img + " " + name; + return row.name; }, - renderExpando: function (level, hasChildren, expanded, object) { - const dir = this.grid.isRTL ? "right" : "left"; - let cls = ".dgrid-expando-icon"; - if (hasChildren) { - cls += ".ui-icon.ui-icon-triangle-1-" + (expanded ? "se" : "e"); - } - //@ts-ignore - const node = put("div" + cls + "[style=margin-" + dir + ": " + (level * (this.indentWidth || 9)) + "px; float: " + dir + ";" + (!hasChildren ? " width: 16px; height: 16px;" : "") + "]"); - node.innerHTML = " "; - return node; - } - }), + }, filesize: { label: nlsHPCC.Size, width: 100, sortable: false, - renderCell: React.useCallback(function (object, value, node, options) { - domClass.add(node, "justify-right"); - node.innerText = Utility.convertedSize(value); - }, []), + justify: "right", + formatter: (value, row) => { + return Utility.convertedSize(value); + }, }, modifiedtime: { label: nlsHPCC.Date, width: 162, sortable: false } + }; + }, []); + + const copyButtons = useCopyButtons(columns, selection, "landingZones"); + + const refreshData = React.useCallback(() => { + if (!lzPath || !selectedDropzone) return; + const request: Partial = { Path: lzPath, }; + if (!isContainer) { + request.Netaddr = selectedDropzone?.TpMachines?.TpMachine[0].Netaddress; + } else { + request.DropZoneName = selectedDropzone?.Name; } - }); + fsService.FileList(request).then(response => { + const files = response?.files?.PhysicalFileStruct.filter(file => !file.isDir).map(file => { + file["NetAddress"] = selectedDropzone?.TpMachines?.TpMachine[0].Netaddress ?? ""; + file["SourcePlane"] = selectedDropzone?.Name ?? ""; + file["fullPath"] = [selectedDropzone?.Path, file.name].join(pathSep); + return file; + }) ?? []; + setData(files); + }); + }, [isContainer, lzPath, pathSep, selectedDropzone]); + + React.useEffect(() => { + refreshData(); + }, [refreshData]); const [DeleteConfirm, setShowDeleteConfirm] = useConfirm({ title: nlsHPCC.Delete, @@ -190,38 +297,38 @@ export const LandingZone: React.FunctionComponent = ({ onSubmit: React.useCallback(() => { selection.forEach((item, idx) => { if (item._isUserFile) { - store.removeUserFile(item); - refreshTable(true); + removeUserFile(item.name); } else { FileSpray.DeleteDropZoneFile({ request: { - DropZoneName: item.DropZone.Name, - NetAddress: item.NetAddress, - Path: item.fullFolderPath, - OS: item.OS, - Names: item.name + DropZoneName: selectedDropzone?.Name ?? "", + NetAddress: item?.NetAddress ?? "", + Path: item?.Path ?? "", + OS: item?.OS ?? "", + Names: item?.name ?? "" }, load: function (response) { - refreshTable(true); + refreshData(); } }); } }); - }, [refreshTable, selection, store]) + }, [refreshData, removeUserFile, selection, selectedDropzone]) }); // Command Bar --- const buttons = React.useMemo((): ICommandBarItemProps[] => [ { key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" }, - onClick: () => refreshTable() + onClick: () => refreshData() }, { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => }, { key: "preview", text: nlsHPCC.Preview, disabled: !selection.length, iconProps: { iconName: "ComplianceAudit" }, onClick: () => { if (selection.length === 1) { - window.location.href = `#/landingzone/preview/${selection[0].getLogicalFile()}`; + const logicalFile = "~file::" + selection[0].NetAddress + FileSpray.lfEncode(selection[0].fullPath); + window.location.href = `#/landingzone/preview/${logicalFile}`; } } }, @@ -238,7 +345,7 @@ export const LandingZone: React.FunctionComponent = ({ selection.forEach(item => { const downloadIframeName = "downloadIframe_" + item.calculatedID; const frame = iframe.create(downloadIframeName); - const url = `${ESPRequest.getBaseURL("FileSpray")}/DownloadFile?Name=${encodeURIComponent(item.name)}&NetAddress=${item.NetAddress}&Path=${encodeURIComponent(item.fullFolderPath)}&OS=${item.OS}&DropZoneName=${item.DropZone.Name}`; + const url = `${ESPRequest.getBaseURL("FileSpray")}/DownloadFile?Name=${encodeURIComponent(item.name)}&NetAddress=${item.Server}&Path=${encodeURIComponent(item.Path)}&DropZoneName=${selectedDropzone.Name}`; iframe.setSrc(frame, url, true); }); } @@ -284,7 +391,7 @@ export const LandingZone: React.FunctionComponent = ({ onClick: () => setShowBlob(true) }, { key: "divider_6", itemType: ContextualMenuItemType.Divider, onRender: () => } - ], [hasFilter, refreshTable, selection, setShowDeleteConfirm]); + ], [hasFilter, refreshData, selection, selectedDropzone?.Name, setShowDeleteConfirm]); // Filter --- const filterFields: Fields = {}; @@ -358,6 +465,56 @@ export const LandingZone: React.FunctionComponent = ({ } }, [setShowFileUpload, setUploadFiles]); + const onOpenChange = React.useCallback((evt: TreeOpenChangeEvent, data: TreeOpenChangeData) => { + const branchData = JSON.parse(data?.target?.dataset?.tree ?? "") ?? {}; + if (data.type === "Click" || data.type === "Enter") { + const path = branchData.path[0] === "/" ? branchData.path : selectedDropzone.Path + "/" + branchData.path; + if (path !== lzPath) { + pushUrl(`#/landingzone/${path.replace(/\//g, "::")}`); + } + return; + } else if (data.type === "ExpandIconClick" && data.open) { + let items = Array.from(treeItems); + if (branchData.path) { + fsService.FileList({ + DropZoneName: selectedDropzone.Name, + Path: branchData.path, + DirectoryOnly: true + }).then(response => { + const files = response?.files?.PhysicalFileStruct?.sort((a, b) => { + if (a.name < b.name) return -1; + if (a.name > b.name) return 1; + return 0; + }) ?? []; + files?.forEach(file => { + const itemPath = file.Path + file.name; + if (items.filter(item => item.value === itemPath).length === 0) { + items.push({ + value: itemPath, + parentValue: file.Path.slice(0, -1), + label: file.name, + icon: file.isDir ? BranchIcon.Directory : BranchIcon.None, + data: { path: itemPath } + }); + if (file.isDir) { + items.push({ + value: itemPath + "__temp", + parentValue: itemPath + }); + } + } + }); + items = items.filter(item => item.value.toString() !== branchData.path + "__temp"); + setTreeItems(items); + }); + } else { + items = items.filter(item => item.value.toString() !== branchData.path + "__temp"); + setTreeItems(items); + } + } + setOpenItems(data.openItems); + }, [lzPath, selectedDropzone, treeItems]); + return } main={ @@ -371,7 +528,22 @@ export const LandingZone: React.FunctionComponent = ({

Drop file(s) to upload.

- + + + + + + + + = ({ refreshTable()} + onSubmit={() => refreshData()} /> } { + switch (iconStr) { + case BranchIcon.Directory: + return open ? : ; + case BranchIcon.Dropzone: + return ; + case BranchIcon.Network: + return ; + case BranchIcon.None: + default: + return null; + } +}; + +export type FlatItem = HeadlessFlatTreeItemProps & { + label?: string, + icon?: BranchIcon, + data?: { [id: string]: any } +}; + +interface TreeViewProps { + treeItems: FlatItem[]; + openItems: Iterable; + onOpenChange?: (_: TreeOpenChangeEvent, data: TreeOpenChangeData) => void; + ariaLabel: string; +} + +export const TreeView: React.FunctionComponent = ({ + treeItems, + openItems, + onOpenChange, + ariaLabel +}) => { + + const flatTree = useHeadlessFlatTree_unstable(treeItems, { onOpenChange: onOpenChange ? onOpenChange : null, openItems }); + const flatTreeProps = { ...flatTree.getTreeProps() }; + const key = useId("FileExplorer"); + if (!treeItems || treeItems.length < 1) return null; + + return + {Array.from(flatTree.items(), (item, idx) => { + const { icon, label, data, ...treeItemProps } = item.getTreeItemProps(); + const open = flatTreeProps.openItems.has(item.value) ? true : false; + return + {label} + ; + })} + ; + +}; \ No newline at end of file diff --git a/esp/src/src-react/components/forms/landing-zone/AddFileForm.tsx b/esp/src/src-react/components/forms/landing-zone/AddFileForm.tsx index 00a269dd037..84eee4f54e7 100644 --- a/esp/src/src-react/components/forms/landing-zone/AddFileForm.tsx +++ b/esp/src/src-react/components/forms/landing-zone/AddFileForm.tsx @@ -17,8 +17,9 @@ const defaultValues: AddFileFormValues = { interface AddFileFormProps { formMinWidth?: number; showForm: boolean; - refreshGrid: (() => void), - store: any; + refreshGrid: (() => void); + addUserFile: ((file) => void); + dropzone: any; setShowForm: (_: boolean) => void; } @@ -26,7 +27,8 @@ export const AddFileForm: React.FunctionComponent = ({ formMinWidth = 300, showForm, refreshGrid, - store, + addUserFile, + dropzone, setShowForm }) => { @@ -39,24 +41,19 @@ export const AddFileForm: React.FunctionComponent = ({ const onSubmit = React.useCallback(() => { handleSubmit( (data, evt) => { - const dropZone = { - ...store.get(data.NetAddress), - NetAddress: data.NetAddress - }; let fullPathParts = data.fullPath.split("/"); if (fullPathParts.length === 1) { fullPathParts = data.fullPath.split("\\"); } const file = { - ...store.get(data.NetAddress + data.fullPath), name: fullPathParts[fullPathParts.length - 1], displayName: fullPathParts[fullPathParts.length - 1], fullPath: data.fullPath, isDir: false, - DropZone: dropZone + DropZone: dropzone, + _isUserFile: true }; - store.addUserFile(file); - refreshGrid(); + addUserFile(file); closeForm(); reset(defaultValues); }, @@ -64,7 +61,7 @@ export const AddFileForm: React.FunctionComponent = ({ console.log(err); } )(); - }, [closeForm, handleSubmit, refreshGrid, reset, store]); + }, [addUserFile, closeForm, dropzone, handleSubmit, reset]); return diff --git a/esp/src/src-react/components/forms/landing-zone/BlobImportForm.tsx b/esp/src/src-react/components/forms/landing-zone/BlobImportForm.tsx index 0ca2e32c585..860d96a222f 100644 --- a/esp/src/src-react/components/forms/landing-zone/BlobImportForm.tsx +++ b/esp/src/src-react/components/forms/landing-zone/BlobImportForm.tsx @@ -144,7 +144,7 @@ export const BlobImportForm: React.FunctionComponent = ({ newValues.selectedFiles[idx] = { TargetName: "", SourceFile: file["fullPath"], - SourcePlane: file?.DropZone?.Name ?? "", + SourcePlane: file["SourcePlane"] ?? "", SourceIP: file["NetAddress"] }; }); diff --git a/esp/src/src-react/components/forms/landing-zone/DelimitedImportForm.tsx b/esp/src/src-react/components/forms/landing-zone/DelimitedImportForm.tsx index 13ffd9b8ccf..9e746d46349 100644 --- a/esp/src/src-react/components/forms/landing-zone/DelimitedImportForm.tsx +++ b/esp/src/src-react/components/forms/landing-zone/DelimitedImportForm.tsx @@ -166,7 +166,7 @@ export const DelimitedImportForm: React.FunctionComponent = ({ TargetRowPath: "/", NumParts: "", SourceFile: file["fullPath"], - SourcePlane: file?.DropZone?.Name ?? "", + SourcePlane: file["SourcePlane"] ?? "", SourceIP: file["NetAddress"] }; }); diff --git a/esp/src/src-react/components/forms/landing-zone/VariableImportForm.tsx b/esp/src/src-react/components/forms/landing-zone/VariableImportForm.tsx index 9f6842a579a..85104aaa995 100644 --- a/esp/src/src-react/components/forms/landing-zone/VariableImportForm.tsx +++ b/esp/src/src-react/components/forms/landing-zone/VariableImportForm.tsx @@ -146,7 +146,7 @@ export const VariableImportForm: React.FunctionComponent = ({ TargetRowTag: "Row", NumParts: "", SourceFile: file["fullPath"], - SourcePlane: file?.DropZone?.Name ?? "", + SourcePlane: file["SourcePlane"] ?? "", SourceIP: file["NetAddress"] }; }); diff --git a/esp/src/src-react/routes.tsx b/esp/src/src-react/routes.tsx index 5344fdfcb2b..8803fd4d2b0 100644 --- a/esp/src/src-react/routes.tsx +++ b/esp/src/src-react/routes.tsx @@ -154,6 +154,11 @@ export const routes: RoutesEx = [ return <_.LandingZone filter={parseSearch(ctx.search) as any} />; }) }, + { + path: "/:Path", action: (ctx, params) => import("./components/LandingZone").then(_ => { + return <_.LandingZone filter={parseSearch(ctx.search) as any} path={params.Path as string} />; + }) + }, { path: "/preview/:logicalFile", action: (ctx, params) => import("./components/HexView").then(_ => { return <_.HexView logicalFile={params.logicalFile as string} />; diff --git a/esp/src/src/FileSpray.ts b/esp/src/src/FileSpray.ts index 1dc36490c37..8dc30f3e31e 100644 --- a/esp/src/src/FileSpray.ts +++ b/esp/src/src/FileSpray.ts @@ -7,7 +7,7 @@ import * as ESPRequest from "./ESPRequest"; declare const dojo; -const lfEncode = (path: string) => { +export const lfEncode = (path: string) => { let retVal = ""; for (let i = 0; i < path.length; ++i) { switch (path[i]) {