浏览代码

HPCC-25736 Refactor React Fields, Groups and Forms

Signed-off-by: Gordon Smith <GordonJSmith@gmail.com>
Gordon Smith 4 年之前
父节点
当前提交
17ddc20490

+ 1 - 1
esp/src/package.json

@@ -104,4 +104,4 @@
     "type": "git",
     "url": "https://github.com/hpcc-systems/HPCC-Platform"
   }
-}
+}

+ 3 - 3
esp/src/src-react/components/About.tsx

@@ -6,7 +6,7 @@ import { fetchStats } from "src/KeyValStore";
 import nlsHPCC from "src/nlsHPCC";
 import { TpGetServerVersion } from "src/WsTopology";
 import { AutosizeHpccJSComponent } from "../layouts/HpccJSAdapter";
-import { Details } from "./Details";
+import { TableGroup } from "./forms/Groups";
 
 interface AboutProps {
     show?: boolean;
@@ -50,11 +50,11 @@ export const About: React.FunctionComponent<AboutProps> = ({
         <Pivot>
             <PivotItem itemKey="about" headerText={nlsHPCC.About}>
                 <div style={{ minHeight: "208px", paddingTop: "32px" }}>
-                    <Details fields={{
+                    <TableGroup fields={{
                         version: { label: nlsHPCC.Version, type: "string", value: version || "???", readonly: true },
                         homepage: { label: nlsHPCC.Homepage, type: "link", href: "https://hpccsystems.com" },
                     }}>
-                    </Details>
+                    </TableGroup>
                 </div>
             </PivotItem>
             <PivotItem itemKey="browser" headerText={nlsHPCC.BrowserStats} alwaysRender>

+ 3 - 2
esp/src/src-react/components/DFUWorkunits.tsx

@@ -8,7 +8,8 @@ import * as Utility from "src/Utility";
 import nlsHPCC from "src/nlsHPCC";
 import { HolyGrail } from "../layouts/HolyGrail";
 import { pushParams } from "../util/history";
-import { Fields, Filter } from "./Filter";
+import { Filter } from "./forms/Filter";
+import { Fields } from "./forms/Fields";
 import { ShortVerticalDivider } from "./Common";
 import { DojoGrid, selector } from "./DojoGrid";
 
@@ -198,7 +199,7 @@ export const DFUWorkunits: React.FunctionComponent<DFUWorkunitsProps> = ({
 
     React.useEffect(() => {
         refreshTable();
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+        // eslint-disable-next-line react-hooks/exhaustive-deps
     }, [filter]);
 
     //  Selection  ---

+ 3 - 2
esp/src/src-react/components/Files.tsx

@@ -9,7 +9,8 @@ import * as Utility from "src/Utility";
 import nlsHPCC from "src/nlsHPCC";
 import { HolyGrail } from "../layouts/HolyGrail";
 import { pushParams } from "../util/history";
-import { Fields, Filter } from "./Filter";
+import { Fields } from "./forms/Fields";
+import { Filter } from "./forms/Filter";
 import { ShortVerticalDivider } from "./Common";
 import { DojoGrid, selector, tree } from "./DojoGrid";
 
@@ -18,7 +19,7 @@ const FilterFields: Fields = {
     "Description": { type: "string", label: nlsHPCC.Description, placeholder: nlsHPCC.SomeDescription },
     "Owner": { type: "string", label: nlsHPCC.Owner, placeholder: nlsHPCC.jsmi },
     "Index": { type: "checkbox", label: nlsHPCC.Index },
-    "NodeGroup": { type: "target-group", label: nlsHPCC.Cluster, placeholder: nlsHPCC.Owner },
+    "NodeGroup": { type: "target-group", label: nlsHPCC.Group, placeholder: nlsHPCC.Cluster },
     "FileSizeFrom": { type: "string", label: nlsHPCC.FromSizes, placeholder: "4096" },
     "FileSizeTo": { type: "string", label: nlsHPCC.ToSizes, placeholder: "16777216" },
     "FileType": { type: "file-type", label: nlsHPCC.FileType },

+ 0 - 305
esp/src/src-react/components/Filter.tsx

@@ -1,305 +0,0 @@
-import * as React from "react";
-import { getTheme, mergeStyleSets, FontWeights, IDragOptions, IIconProps, ContextualMenu, DefaultButton, PrimaryButton, IconButton, Dropdown, IStackStyles, Modal, Stack, IDropdownProps, IDropdownOption } from "@fluentui/react";
-import { useId } from "@fluentui/react-hooks";
-import { TpGroupQuery } from "src/WsTopology";
-import nlsHPCC from "src/nlsHPCC";
-import { Details } from "./Details";
-
-type FieldType = "string" | "checkbox" | "datetime" |
-    "workunit-state" |
-    "file-type" | "file-sortby" |
-    "queries-suspend-state" | "queries-active-state" |
-    "target-cluster" | "target-group" |
-    "logicalfile-type" | "dfuworkunit-state";
-
-interface BaseField {
-    type: FieldType;
-    label: string;
-    disabled?: (params) => boolean;
-    placeholder?: string;
-    readonly?: boolean;
-}
-
-interface StringField extends BaseField {
-    type: "string";
-    value?: string;
-}
-
-interface DateTimeField extends BaseField {
-    type: "datetime";
-    value?: string;
-}
-
-interface CheckboxField extends BaseField {
-    type: "checkbox";
-    value?: boolean;
-}
-
-interface WorkunitStateField extends BaseField {
-    type: "workunit-state";
-    value?: string;
-}
-
-interface FileTypeField extends BaseField {
-    type: "file-type";
-    value?: string;
-}
-
-interface FileSortByField extends BaseField {
-    type: "file-sortby";
-    value?: string;
-}
-
-interface QueriesSuspendStateField extends BaseField {
-    type: "queries-suspend-state";
-    value?: string;
-}
-
-interface QueriesActiveStateField extends BaseField {
-    type: "queries-active-state";
-    value?: string;
-}
-
-interface TargetClusterField extends BaseField {
-    type: "target-cluster";
-    value?: string;
-}
-
-interface TargetGroupField extends BaseField {
-    type: "target-group";
-    value?: string;
-}
-
-interface LogicalFileType extends BaseField {
-    type: "logicalfile-type";
-    value?: string;
-}
-
-interface DFUWorkunitStateField extends BaseField {
-    type: "dfuworkunit-state";
-    value?: string;
-}
-
-type Field = StringField | CheckboxField | DateTimeField |
-    WorkunitStateField |
-    FileTypeField | FileSortByField |
-    QueriesSuspendStateField | QueriesActiveStateField |
-    TargetClusterField | TargetGroupField |
-    LogicalFileType | DFUWorkunitStateField;
-
-export type Fields = { [name: string]: Field };
-export type Values = { [name: string]: string | number | boolean | (string | number | boolean)[] };
-
-const fieldsToRequest = (fields: Fields) => {
-    const retVal: Values = {};
-    for (const name in fields) {
-        if (!fields[name].disabled(fields)) {
-            retVal[name] = fields[name].value;
-        }
-    }
-    return retVal;
-};
-
-export const TargetGroupTextField: React.FunctionComponent<IDropdownProps> = (props) => {
-
-    const [targetGroups, setTargetGroups] = React.useState<IDropdownOption[]>([]);
-
-    React.useEffect(() => {
-        TpGroupQuery({}).then(({ TpGroupQueryResponse }) => {
-            setTargetGroups(
-                TpGroupQueryResponse.TpGroups.TpGroup.map(n => {
-                    return {
-                        key: n.Name,
-                        text: n.Name + (n.Name !== n.Kind ? ` (${n.Kind})` : "")
-                    };
-                })
-            );
-        });
-    }, []);
-
-    return <Dropdown
-        {...props}
-        options={targetGroups}
-    />;
-};
-
-interface FormContentProps {
-    fields: Fields;
-    doSubmit: boolean;
-    doReset: boolean;
-    onSubmit: (fields: Values) => void;
-    onReset: (fields: Values) => void;
-}
-
-export const FormContent: React.FunctionComponent<FormContentProps> = ({
-    fields,
-    doSubmit,
-    doReset,
-    onSubmit,
-    onReset
-}) => {
-
-    const [localFields, setLocalFields] = React.useState<Fields>({ ...fields });
-
-    React.useEffect(() => {
-        if (doSubmit === false) return;
-        onSubmit(fieldsToRequest(localFields));
-        // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [doSubmit]);
-
-    React.useEffect(() => {
-        if (doReset === false) return;
-        for (const key in localFields) {
-            delete localFields[key].value;
-        }
-        setLocalFields(localFields);
-        onReset(fieldsToRequest(localFields));
-        // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [doReset]);
-
-    return <Details fields={localFields} onChange={(id, value) => {
-        const field = localFields[id];
-        switch (field.type) {
-            case "checkbox":
-                field.value = value;
-                setLocalFields({ ...localFields });
-                break;
-            default:
-                field.value = value;
-                setLocalFields({ ...localFields });
-                break;
-        }
-    }} />;
-};
-
-interface FilterProps {
-    filterFields: Fields;
-    onApply: (values: Values) => void;
-
-    showFilter: boolean;
-    setShowFilter: (_: boolean) => void;
-}
-
-export const Filter: React.FunctionComponent<FilterProps> = ({
-    filterFields,
-    onApply,
-    showFilter,
-    setShowFilter
-}) => {
-
-    const [doSubmit, setDoSubmit] = React.useState(false);
-    const [doReset, setDoReset] = React.useState(false);
-
-    const closeFilter = () => setShowFilter(false);
-
-    const titleId = useId("title");
-
-    const dragOptions: IDragOptions = {
-        moveMenuItemText: "Move",
-        closeMenuItemText: "Close",
-        menu: ContextualMenu,
-    };
-
-    const theme = getTheme();
-
-    const contentStyles = mergeStyleSets({
-        container: {
-            display: "flex",
-            flexFlow: "column nowrap",
-            alignItems: "stretch",
-        },
-        header: [
-            {
-                flex: "1 1 auto",
-                borderTop: `4px solid ${theme.palette.themePrimary}`,
-                color: theme.palette.neutralPrimary,
-                display: "flex",
-                alignItems: "center",
-                fontWeight: FontWeights.semibold,
-                padding: "12px 12px 14px 24px",
-            },
-        ],
-        body: {
-            flex: "4 4 auto",
-            padding: "0 24px 24px 24px",
-            overflowY: "hidden",
-            selectors: {
-                p: { margin: "14px 0" },
-                "p:first-child": { marginTop: 0 },
-                "p:last-child": { marginBottom: 0 },
-            },
-        },
-    });
-
-    const cancelIcon: IIconProps = { iconName: "Cancel" };
-    const iconButtonStyles = {
-        root: {
-            color: theme.palette.neutralPrimary,
-            marginLeft: "auto",
-            marginTop: "4px",
-            marginRight: "2px",
-        },
-        rootHovered: {
-            color: theme.palette.neutralDark,
-        },
-    };
-    const buttonStackStyles: IStackStyles = {
-        root: {
-            height: "56px",
-        },
-    };
-    return <Modal
-        titleAriaId={titleId}
-        isOpen={showFilter}
-        onDismiss={closeFilter}
-        isBlocking={false}
-        containerClassName={contentStyles.container}
-        dragOptions={dragOptions}
-    >
-        <div className={contentStyles.header}>
-            <span id={titleId}>Filter</span>
-            <IconButton
-                styles={iconButtonStyles}
-                iconProps={cancelIcon}
-                ariaLabel="Close popup modal"
-                onClick={closeFilter}
-            />
-        </div>
-        <div className={contentStyles.body}>
-            <Stack>
-                <FormContent
-                    fields={filterFields}
-                    doSubmit={doSubmit}
-                    doReset={doReset}
-                    onSubmit={fields => {
-                        setDoSubmit(false);
-                        onApply(fields);
-                    }}
-                    onReset={() => {
-                        setDoReset(false);
-                    }}
-                />
-            </Stack>
-            <Stack
-                horizontal
-                horizontalAlign="space-between"
-                verticalAlign="end"
-                styles={buttonStackStyles}
-            >
-                <DefaultButton
-                    text={nlsHPCC.Clear}
-                    onClick={() => {
-                        setDoReset(true);
-                    }}
-                />
-                <PrimaryButton
-                    text={nlsHPCC.Apply}
-                    onClick={() => {
-                        setDoSubmit(true);
-                        closeFilter();
-                    }}
-                />
-            </Stack>
-        </div>
-    </Modal>;
-};

+ 2 - 1
esp/src/src-react/components/Queries.tsx

@@ -7,7 +7,8 @@ import * as Utility from "src/Utility";
 import nlsHPCC from "src/nlsHPCC";
 import { HolyGrail } from "../layouts/HolyGrail";
 import { pushParams } from "../util/history";
-import { Fields, Filter } from "./Filter";
+import { Fields } from "./forms/Fields";
+import { Filter } from "./forms/Filter";
 import { ShortVerticalDivider } from "./Common";
 import { DojoGrid, selector } from "./DojoGrid";
 

+ 2 - 2
esp/src/src-react/components/WorkunitDetails.tsx

@@ -13,7 +13,7 @@ import { ShortVerticalDivider } from "./Common";
 import { Results } from "./Results";
 import { Variables } from "./Variables";
 import { SourceFiles } from "./SourceFiles";
-import { Details } from "./Details";
+import { TableGroup } from "./forms/Groups";
 import { InfoGrid } from "./InfoGrid";
 import { WUXMLSourceEditor } from "./SourceEditor";
 import { Workflows } from "./Workflows";
@@ -139,7 +139,7 @@ export const WorkunitDetails: React.FunctionComponent<WorkunitDetailsProps> = ({
                                             <WUStatus wuid={wuid}></WUStatus>
                                         </div>
                                     </Sticky>
-                                    <Details fields={{
+                                    <TableGroup fields={{
                                         "wuid": { label: nlsHPCC.WUID, type: "string", value: wuid, readonly: true },
                                         "action": { label: nlsHPCC.Action, type: "string", value: workunit?.ActionEx, readonly: true },
                                         "state": { label: nlsHPCC.State, type: "string", value: workunit?.State, readonly: true },

+ 6 - 5
esp/src/src-react/components/Workunits.tsx

@@ -8,7 +8,8 @@ import * as Utility from "src/Utility";
 import nlsHPCC from "src/nlsHPCC";
 import { HolyGrail } from "../layouts/HolyGrail";
 import { pushParams } from "../util/history";
-import { Fields, Filter } from "./Filter";
+import { Fields } from "./forms/Fields";
+import { Filter } from "./forms/Filter";
 import { ShortVerticalDivider } from "./Common";
 import { DojoGrid, selector } from "./DojoGrid";
 
@@ -17,8 +18,8 @@ const FilterFields: Fields = {
     "Wuid": { type: "string", label: nlsHPCC.WUID, placeholder: "W20200824-060035" },
     "Owner": { type: "string", label: nlsHPCC.Owner, placeholder: nlsHPCC.jsmi },
     "Jobname": { type: "string", label: nlsHPCC.JobName, placeholder: nlsHPCC.log_analysis_1 },
-    "Cluster": { type: "target-cluster", label: nlsHPCC.Cluster, placeholder: nlsHPCC.Owner },
-    "State": { type: "workunit-state", label: nlsHPCC.State, placeholder: nlsHPCC.Created },
+    "Cluster": { type: "target-cluster", label: nlsHPCC.Cluster, placeholder: "" },
+    "State": { type: "workunit-state", label: nlsHPCC.State, placeholder: "" },
     "ECL": { type: "string", label: nlsHPCC.ECL, placeholder: nlsHPCC.dataset },
     "LogicalFile": { type: "string", label: nlsHPCC.LogicalFile, placeholder: nlsHPCC.somefile },
     "LogicalFileSearchType": { type: "logicalfile-type", label: nlsHPCC.LogicalFileType, placeholder: "", disabled: (params: Fields) => !params.LogicalFile.value },
@@ -204,8 +205,8 @@ export const Workunits: React.FunctionComponent<WorkunitsProps> = ({
 
     //  Filter  ---
     const filterFields: Fields = {};
-    for (const field in FilterFields) {
-        filterFields[field] = { ...FilterFields[field], value: filter[field] };
+    for (const fieldID in FilterFields) {
+        filterFields[fieldID] = { ...FilterFields[fieldID], value: filter[fieldID] };
     }
 
     React.useEffect(() => {

+ 172 - 74
esp/src/src-react/components/Details.tsx

@@ -1,5 +1,5 @@
 import * as React from "react";
-import { Checkbox, Dropdown, TextField, IDropdownProps, IDropdownOption, Label, Link } from "@fluentui/react";
+import { Checkbox, Dropdown as DropdownBase, TextField, IDropdownOption, Link } from "@fluentui/react";
 import { TextField as MaterialUITextField } from "@material-ui/core";
 import { Topology, TpLogicalClusterQuery } from "@hpcc-js/comms";
 import { TpGroupQuery } from "src/WsTopology";
@@ -7,21 +7,112 @@ import { States } from "src/WsWorkunits";
 import { States as DFUStates } from "src/FileSpray";
 import nlsHPCC from "src/nlsHPCC";
 
-type FieldType = "string" | "checkbox" | "datetime" | "link" |
+interface DropdownProps {
+    key?: string;
+    label?: string;
+    options?: IDropdownOption[];
+    selectedKey?: string;
+    optional?: boolean;
+    onChange?: (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption, index?: number) => void;
+    placeholder?: string;
+    className?: string;
+}
+
+const Dropdown: React.FunctionComponent<DropdownProps> = ({
+    key,
+    label,
+    options = [],
+    selectedKey,
+    optional = false,
+    onChange,
+    placeholder,
+    className
+}) => {
+
+    const [selOptions, setSelOptions] = React.useState<IDropdownOption[]>([]);
+
+    React.useEffect(() => {
+        setSelOptions(optional ? [{ key: "", text: "" }, ...options] : [...options]);
+    }, [optional, options, selectedKey]);
+
+    return <DropdownBase key={key} label={label} className={className} defaultSelectedKey={selectedKey} onChange={onChange} placeholder={placeholder} options={selOptions} />;
+};
+
+export type FieldType = "string" | "checkbox" | "datetime" | "link" |
     "workunit-state" |
     "file-type" | "file-sortby" |
     "queries-suspend-state" | "queries-active-state" |
     "target-cluster" | "target-group" |
     "logicalfile-type" | "dfuworkunit-state";
 
-const states = Object.keys(States).map(s => States[s]);
-const dfustates = Object.keys(DFUStates).map(s => DFUStates[s]);
+export type Values = { [name: string]: string | number | boolean | (string | number | boolean)[] };
 
 interface BaseField {
     type: FieldType;
     label: string;
     disabled?: (params) => boolean;
     placeholder?: string;
+    readonly?: boolean;
+}
+
+interface StringField extends BaseField {
+    type: "string";
+    value?: string;
+}
+
+interface DateTimeField extends BaseField {
+    type: "datetime";
+    value?: string;
+}
+
+interface CheckboxField extends BaseField {
+    type: "checkbox";
+    value?: boolean;
+}
+
+interface WorkunitStateField extends BaseField {
+    type: "workunit-state";
+    value?: string;
+}
+
+interface FileTypeField extends BaseField {
+    type: "file-type";
+    value?: string;
+}
+
+interface FileSortByField extends BaseField {
+    type: "file-sortby";
+    value?: string;
+}
+
+interface QueriesSuspendStateField extends BaseField {
+    type: "queries-suspend-state";
+    value?: string;
+}
+
+interface QueriesActiveStateField extends BaseField {
+    type: "queries-active-state";
+    value?: string;
+}
+
+interface TargetClusterField extends BaseField {
+    type: "target-cluster";
+    value?: string;
+}
+
+interface TargetGroupField extends BaseField {
+    type: "target-group";
+    value?: string;
+}
+
+interface LogicalFileType extends BaseField {
+    type: "logicalfile-type";
+    value?: string;
+}
+
+interface DFUWorkunitStateField extends BaseField {
+    type: "dfuworkunit-state";
+    value?: string;
 }
 
 interface StringField extends BaseField {
@@ -39,6 +130,7 @@ interface DateTimeField extends BaseField {
 interface LinkField extends BaseField {
     type: "link";
     href: string;
+    value?: undefined;
 }
 
 interface CheckboxField extends BaseField {
@@ -100,35 +192,61 @@ type Field = StringField | CheckboxField | DateTimeField | LinkField |
 
 export type Fields = { [id: string]: Field };
 
-const TargetClusterTextField: React.FunctionComponent<IDropdownProps> = (props) => {
+export interface TargetClusterTextFieldProps extends DropdownProps {
+    key: string;
+    label?: string;
+    selectedKey?: string;
+    className?: string;
+    onChange?: (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption, index?: number) => void;
+    placeholder?: string;
+}
+
+export const TargetClusterTextField: React.FunctionComponent<TargetClusterTextFieldProps> = ({
+    key,
+    label,
+    selectedKey,
+    className,
+    onChange,
+    placeholder
+}) => {
 
     const [targetClusters, setTargetClusters] = React.useState<IDropdownOption[]>([]);
 
     React.useEffect(() => {
         const topology = new Topology({ baseUrl: "" });
         topology.fetchLogicalClusters().then((response: TpLogicalClusterQuery.TpLogicalCluster[]) => {
-            setTargetClusters(
-                [
-                    { Name: "", Type: "", LanguageVersion: "", Process: "", Queue: "" },
-                    ...response
-                ]
-                    .map(n => {
-                        return {
-                            key: n.Name,
-                            text: n.Name + (n.Name !== n.Type ? ` (${n.Type})` : "")
-                        };
-                    })
+            setTargetClusters(response
+                .map((n, i) => {
+                    return {
+                        key: n.Name || "unknown",
+                        text: n.Name + (n.Name !== n.Type ? ` (${n.Type})` : ""),
+                    };
+                })
             );
         });
+        // eslint-disable-next-line react-hooks/exhaustive-deps
     }, []);
 
-    return <Dropdown
-        {...props}
-        options={targetClusters}
-    />;
+    return <Dropdown key={key} label={label} selectedKey={selectedKey} optional className={className} onChange={onChange} placeholder={placeholder} options={targetClusters} />;
 };
 
-const TargetGroupTextField: React.FunctionComponent<IDropdownProps> = (props) => {
+export interface TargetGroupTextFieldProps {
+    key: string;
+    label?: string;
+    selectedKey?: string;
+    className?: string;
+    onChange?: (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption, index?: number) => void;
+    placeholder?: string;
+}
+
+export const TargetGroupTextField: React.FunctionComponent<TargetGroupTextFieldProps> = ({
+    key,
+    label,
+    selectedKey,
+    className,
+    onChange,
+    placeholder
+}) => {
 
     const [targetGroups, setTargetGroups] = React.useState<IDropdownOption[]>([]);
 
@@ -145,23 +263,14 @@ const TargetGroupTextField: React.FunctionComponent<IDropdownProps> = (props) =>
         });
     }, []);
 
-    return <Dropdown
-        {...props}
-        options={targetGroups}
-    />;
+    return <Dropdown key={key} label={label} selectedKey={selectedKey} className={className} onChange={onChange} placeholder={placeholder} options={targetGroups} />;
 };
 
-interface DetailsProps {
-    fields: Fields;
-    onChange?: (id: string, newValue: any) => void;
-}
-
-export const Details: React.FunctionComponent<DetailsProps> = ({
-    fields,
-    onChange = (id: string, newValue: any) => { }
-}) => {
+const states = Object.keys(States).map(s => States[s]);
+const dfustates = Object.keys(DFUStates).map(s => DFUStates[s]);
 
-    const formFields: { id: string, label: string, field: any }[] = [];
+export function createInputs(fields: Fields, onChange?: (id: string, newValue: any) => void) {
+    const retVal: { id: string, label: string, field: any }[] = [];
     for (const fieldID in fields) {
         const field = fields[fieldID];
         if (!field.disabled) {
@@ -170,7 +279,7 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
         switch (field.type) {
             case "string":
                 field.value = field.value || "";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <TextField
@@ -188,7 +297,7 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
             case "checkbox":
                 field.value = field.value || false;
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <Checkbox
@@ -201,7 +310,7 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
             case "datetime":
                 field.value = field.value || "";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <MaterialUITextField
@@ -220,7 +329,7 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
             case "link":
                 field.href = field.href;
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <Link
@@ -232,12 +341,13 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
             case "workunit-state":
                 field.value = field.value || "";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <Dropdown
                         key={fieldID}
-                        defaultSelectedKey={field.value}
+                        selectedKey={field.value}
+                        optional
                         options={states.map(state => {
                             return {
                                 key: state,
@@ -251,12 +361,12 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
             case "file-type":
                 field.value = field.value || "";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <Dropdown
                         key={fieldID}
-                        defaultSelectedKey={field.value}
+                        selectedKey={field.value}
                         options={[
                             { key: "", text: nlsHPCC.LogicalFilesAndSuperfiles },
                             { key: "Logical Files Only", text: nlsHPCC.LogicalFilesOnly },
@@ -270,14 +380,14 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
             case "file-sortby":
                 field.value = field.value || "";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <Dropdown
                         key={fieldID}
-                        defaultSelectedKey={field.value}
+                        selectedKey={field.value}
+                        optional
                         options={[
-                            { key: "", text: "" },
                             { key: "Newest", text: nlsHPCC.Newest },
                             { key: "Oldest", text: nlsHPCC.Oldest },
                             { key: "Smallest", text: nlsHPCC.Smallest },
@@ -290,14 +400,14 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
             case "queries-suspend-state":
                 field.value = field.value || "";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <Dropdown
                         key={fieldID}
-                        defaultSelectedKey={field.value}
+                        selectedKey={field.value}
+                        optional
                         options={[
-                            { key: "", text: "" },
                             { key: "Not suspended", text: nlsHPCC.NotSuspended },
                             { key: "Suspended", text: nlsHPCC.Suspended },
                             { key: "Suspended by user", text: nlsHPCC.SuspendedByUser },
@@ -311,14 +421,14 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
             case "queries-active-state":
                 field.value = field.value || "";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <Dropdown
                         key={fieldID}
-                        defaultSelectedKey={field.value}
+                        selectedKey={field.value}
+                        optional
                         options={[
-                            { key: "", text: "" },
                             { key: "1", text: nlsHPCC.Active },
                             { key: "0", text: nlsHPCC.NotActive }
                         ]}
@@ -329,40 +439,38 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
             case "target-cluster":
                 field.value = field.value || "";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <TargetClusterTextField
                         key={fieldID}
-                        defaultSelectedKey={field.value}
+                        selectedKey={field.value}
                         onChange={(ev, row) => onChange(fieldID, row.key)}
                         placeholder={field.placeholder}
-                        options={[]}
                     />
                 });
                 break;
             case "target-group":
                 field.value = field.value || "";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <TargetGroupTextField
                         key={fieldID}
-                        defaultSelectedKey=""
+                        selectedKey={field.value}
                         onChange={(ev, row) => onChange(fieldID, row.key)}
                         placeholder={field.placeholder}
-                        options={[]}
                     />
                 });
                 break;
             case "dfuworkunit-state":
                 field.value = field.value || "";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <Dropdown
                         key={fieldID}
-                        defaultSelectedKey=""
+                        optional
                         options={dfustates.map(state => {
                             return {
                                 key: state,
@@ -376,12 +484,12 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
             case "logicalfile-type":
                 field.value = field.value || "Created";
-                formFields.push({
+                retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <Dropdown
                         key={fieldID}
-                        defaultSelectedKey=""
+                        optional
                         options={[
                             { key: "Created", text: nlsHPCC.CreatedByWorkunit },
                             { key: "Used", text: nlsHPCC.UsedByWorkunit }
@@ -393,15 +501,5 @@ export const Details: React.FunctionComponent<DetailsProps> = ({
                 break;
         }
     }
-
-    return <table style={{ padding: 4 }}>
-        <tbody>
-            {formFields.map((ff) => {
-                return <tr key={ff.id}>
-                    <td style={{ whiteSpace: "nowrap" }}><Label>{ff.label}</Label></td>
-                    <td style={{ width: "80%", paddingLeft: 8 }}>{ff.field}</td>
-                </tr>;
-            })}
-        </tbody>
-    </table>;
-};
+    return retVal;
+}

+ 138 - 0
esp/src/src-react/components/forms/Filter.tsx

@@ -0,0 +1,138 @@
+import * as React from "react";
+import { getTheme, mergeStyleSets, FontWeights, IDragOptions, IIconProps, ContextualMenu, DefaultButton, PrimaryButton, IconButton, IStackStyles, Modal, Stack } from "@fluentui/react";
+import { useId } from "@fluentui/react-hooks";
+import nlsHPCC from "src/nlsHPCC";
+import { Fields, Values } from "./Fields";
+import { TableForm } from "./Forms";
+
+interface FilterProps {
+    filterFields: Fields;
+    onApply: (values: Values) => void;
+
+    showFilter: boolean;
+    setShowFilter: (_: boolean) => void;
+}
+
+export const Filter: React.FunctionComponent<FilterProps> = ({
+    filterFields,
+    onApply,
+    showFilter,
+    setShowFilter
+}) => {
+
+    const [doSubmit, setDoSubmit] = React.useState(false);
+    const [doReset, setDoReset] = React.useState(false);
+
+    const closeFilter = () => setShowFilter(false);
+
+    const titleId = useId("title");
+
+    const dragOptions: IDragOptions = {
+        moveMenuItemText: "Move",
+        closeMenuItemText: "Close",
+        menu: ContextualMenu,
+    };
+
+    const theme = getTheme();
+
+    const contentStyles = mergeStyleSets({
+        container: {
+            display: "flex",
+            flexFlow: "column nowrap",
+            alignItems: "stretch",
+        },
+        header: [
+            {
+                flex: "1 1 auto",
+                borderTop: `4px solid ${theme.palette.themePrimary}`,
+                color: theme.palette.neutralPrimary,
+                display: "flex",
+                alignItems: "center",
+                fontWeight: FontWeights.semibold,
+                padding: "12px 12px 14px 24px",
+            },
+        ],
+        body: {
+            flex: "4 4 auto",
+            padding: "0 24px 24px 24px",
+            overflowY: "hidden",
+            selectors: {
+                p: { margin: "14px 0" },
+                "p:first-child": { marginTop: 0 },
+                "p:last-child": { marginBottom: 0 },
+            },
+        },
+    });
+
+    const cancelIcon: IIconProps = { iconName: "Cancel" };
+    const iconButtonStyles = {
+        root: {
+            color: theme.palette.neutralPrimary,
+            marginLeft: "auto",
+            marginTop: "4px",
+            marginRight: "2px",
+        },
+        rootHovered: {
+            color: theme.palette.neutralDark,
+        },
+    };
+    const buttonStackStyles: IStackStyles = {
+        root: {
+            height: "56px",
+        },
+    };
+    return <Modal
+        titleAriaId={titleId}
+        isOpen={showFilter}
+        onDismiss={closeFilter}
+        isBlocking={false}
+        containerClassName={contentStyles.container}
+        dragOptions={dragOptions}
+    >
+        <div className={contentStyles.header}>
+            <span id={titleId}>Filter</span>
+            <IconButton
+                styles={iconButtonStyles}
+                iconProps={cancelIcon}
+                ariaLabel="Close popup modal"
+                onClick={closeFilter}
+            />
+        </div>
+        <div className={contentStyles.body}>
+            <Stack>
+                <TableForm
+                    fields={filterFields}
+                    doSubmit={doSubmit}
+                    doReset={doReset}
+                    onSubmit={fields => {
+                        setDoSubmit(false);
+                        onApply(fields);
+                    }}
+                    onReset={() => {
+                        setDoReset(false);
+                    }}
+                />
+            </Stack>
+            <Stack
+                horizontal
+                horizontalAlign="space-between"
+                verticalAlign="end"
+                styles={buttonStackStyles}
+            >
+                <DefaultButton
+                    text={nlsHPCC.Clear}
+                    onClick={() => {
+                        setDoReset(true);
+                    }}
+                />
+                <PrimaryButton
+                    text={nlsHPCC.Apply}
+                    onClick={() => {
+                        setDoSubmit(true);
+                        closeFilter();
+                    }}
+                />
+            </Stack>
+        </div>
+    </Modal>;
+};

+ 54 - 0
esp/src/src-react/components/forms/Forms.tsx

@@ -0,0 +1,54 @@
+import * as React from "react";
+import { TableGroup } from "./Groups";
+import { Fields, Values } from "./Fields";
+
+const fieldsToRequest = (fields: Fields) => {
+    const retVal: Values = {};
+    for (const name in fields) {
+        if (!fields[name].disabled(fields)) {
+            retVal[name] = fields[name].value;
+        }
+    }
+    return retVal;
+};
+
+interface TableFormProps {
+    fields: Fields;
+    doSubmit: boolean;
+    doReset: boolean;
+    onSubmit: (fields: Values) => void;
+    onReset: (fields: Values) => void;
+}
+
+export const TableForm: React.FunctionComponent<TableFormProps> = ({
+    fields,
+    doSubmit,
+    doReset,
+    onSubmit,
+    onReset
+}) => {
+
+    const [localFields, setLocalFields] = React.useState<Fields>({ ...fields });
+
+    React.useEffect(() => {
+        if (doSubmit === false) return;
+        onSubmit(fieldsToRequest(localFields));
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [doSubmit]);
+
+    React.useEffect(() => {
+        if (doReset === false) return;
+        for (const key in localFields) {
+            delete localFields[key].value;
+        }
+        setLocalFields(localFields);
+        onReset(fieldsToRequest(localFields));
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [doReset]);
+
+    return <TableGroup fields={localFields} onChange={(id, value) => {
+        localFields[id].value = value;
+        setLocalFields({ ...localFields });
+    }} />;
+};
+

+ 46 - 0
esp/src/src-react/components/forms/Groups.tsx

@@ -0,0 +1,46 @@
+import * as React from "react";
+import { Label } from "@fluentui/react";
+import { createInputs, Fields } from "./Fields";
+
+interface FieldsTableProps {
+    fields: Fields;
+    onChange?: (id: string, newValue: any) => void;
+}
+
+export const TableGroup: React.FunctionComponent<FieldsTableProps> = ({
+    fields,
+    onChange = (id: string, newValue: any) => { }
+}) => {
+
+    const formFields: { id: string, label: string, field: any }[] = createInputs(fields, onChange);
+
+    return <table style={{ padding: 4 }}>
+        <tbody>
+            {formFields.map((ff) => {
+                return <tr key={ff.id}>
+                    <td style={{ whiteSpace: "nowrap" }}><Label htmlFor={ff.id}>{ff.label}</Label></td>
+                    <td style={{ width: "80%", paddingLeft: 8 }}>{ff.field}</td>
+                </tr>;
+            })}
+        </tbody>
+    </table>;
+};
+
+export const SimpleGroup: React.FunctionComponent<FieldsTableProps> = ({
+    fields,
+    onChange = (id: string, newValue: any) => { }
+}) => {
+
+    const formFields: { id: string, label: string, field: any }[] = createInputs(fields, onChange);
+
+    return <>
+        {
+            formFields.map((ff) => {
+                return <>
+                    <Label htmlFor={ff.id}>{ff.label}</Label>
+                    {ff.field}
+                </>;
+            })
+        }
+    </>;
+};