Feat UI upload software (#18575)
relates to #18326 Add ability to add software from the UI. This includes - new button on software page to open add software modal - new add software modal to add software. > Note: still need to do form error validation but will do on another PR - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Manual QA for all new/changed functionality
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- add ability to upload software from the UI
|
||||
@@ -0,0 +1,102 @@
|
||||
import classnames from "classnames";
|
||||
import React, { ReactNode } from "react";
|
||||
import AceEditor from "react-ace";
|
||||
|
||||
const baseClass = "editor";
|
||||
|
||||
interface IEditorProps {
|
||||
focus?: boolean;
|
||||
label?: string;
|
||||
error?: string | null;
|
||||
/**
|
||||
* Help text to display below the editor.
|
||||
*/
|
||||
helpText?: ReactNode;
|
||||
/** Sets the value of the input. Use this if you'd like the editor
|
||||
* to be a controlled component */
|
||||
value?: string;
|
||||
/** Sets the default value of the input. Use this if you'd like the editor
|
||||
* to be an uncontrolled component */
|
||||
defaultValue?: string;
|
||||
/** Enabled wrapping lines.
|
||||
* @default false
|
||||
*/
|
||||
wrapEnabled?: boolean;
|
||||
/** A unique name for the editor.
|
||||
* @default "editor"
|
||||
*/
|
||||
name?: string;
|
||||
maxLines?: number;
|
||||
className?: string;
|
||||
onChange: (value: string, event?: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a generic editor that uses the AceEditor component.
|
||||
* TODO: We should move FleetAce and YamlAce into here and deprecate importing
|
||||
* them directly. This component should be used for all editor components and
|
||||
* be configurable from the props. We should look into dynmaic imports for
|
||||
* this.
|
||||
*/
|
||||
const Editor = ({
|
||||
helpText,
|
||||
label,
|
||||
error,
|
||||
focus,
|
||||
value,
|
||||
defaultValue,
|
||||
wrapEnabled = false,
|
||||
name = "editor",
|
||||
maxLines = 20,
|
||||
className,
|
||||
onChange,
|
||||
}: IEditorProps) => {
|
||||
const classNames = classnames(baseClass, className, {
|
||||
[`${baseClass}__error`]: !!error,
|
||||
});
|
||||
|
||||
const renderLabel = () => {
|
||||
const labelText = error || label;
|
||||
const labelClassName = classnames(`${baseClass}__label`, {
|
||||
[`${baseClass}__label--error`]: !!error,
|
||||
});
|
||||
|
||||
if (!labelText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={labelClassName}>{labelText}</div>;
|
||||
};
|
||||
|
||||
const renderHelpText = () => {
|
||||
if (helpText) {
|
||||
return <div className={`${baseClass}__help-text`}>{helpText}</div>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
{renderLabel()}
|
||||
<AceEditor
|
||||
wrapEnabled={wrapEnabled}
|
||||
name={name}
|
||||
className={baseClass}
|
||||
fontSize={14}
|
||||
theme="fleet"
|
||||
width="100%"
|
||||
minLines={2}
|
||||
maxLines={maxLines}
|
||||
editorProps={{ $blockScrolling: Infinity }}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
tabSize={2}
|
||||
focus={focus}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{renderHelpText()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
||||
@@ -0,0 +1,21 @@
|
||||
.editor {
|
||||
|
||||
&__label {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
|
||||
&--error {
|
||||
color: $core-vibrant-red;
|
||||
}
|
||||
}
|
||||
|
||||
&__help-text {
|
||||
@include help-text;
|
||||
}
|
||||
|
||||
&__error {
|
||||
.ace-fleet {
|
||||
border: 1px solid $core-vibrant-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./Editor";
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import classnames from "classnames";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
import Card from "components/Card";
|
||||
import { GraphicNames } from "components/graphics";
|
||||
import Graphic from "components/Graphic";
|
||||
import Icon from "components/Icon";
|
||||
|
||||
const baseClass = "file-uploader";
|
||||
|
||||
@@ -32,9 +33,20 @@ interface IFileUploaderProps {
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
|
||||
*/
|
||||
accept?: string;
|
||||
/** The text to display on the upload button */
|
||||
/** The text to display on the upload button
|
||||
* @default "Upload"
|
||||
*/
|
||||
buttonMessage?: string;
|
||||
className?: string;
|
||||
/** renders the button to open the file uploader to appear as a button or
|
||||
* a link.
|
||||
* @default "button"
|
||||
*/
|
||||
buttonType?: "button" | "link";
|
||||
/** If provided FileUploader will display this component when the file is
|
||||
* selected. This is used for previewing the file before uploading.
|
||||
*/
|
||||
filePreview?: ReactNode;
|
||||
onFileUpload: (files: FileList | null) => void;
|
||||
}
|
||||
|
||||
@@ -47,11 +59,26 @@ const FileUploader = ({
|
||||
additionalInfo,
|
||||
isLoading = false,
|
||||
accept,
|
||||
buttonMessage = "Upload",
|
||||
filePreview,
|
||||
className,
|
||||
buttonMessage = "Upload",
|
||||
buttonType = "button",
|
||||
onFileUpload,
|
||||
}: IFileUploaderProps) => {
|
||||
const classes = classnames(baseClass, className);
|
||||
const [isFileSelected, setIsFileSelected] = useState(false);
|
||||
|
||||
const classes = classnames(baseClass, className, {
|
||||
[`${baseClass}__file-preview`]: filePreview !== undefined && isFileSelected,
|
||||
});
|
||||
const buttonVariant = buttonType === "button" ? "brand" : "text-icon";
|
||||
|
||||
const onFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
onFileUpload(files);
|
||||
setIsFileSelected(true);
|
||||
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const renderGraphics = () => {
|
||||
const graphicNamesArr =
|
||||
@@ -64,29 +91,36 @@ const FileUploader = ({
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card color="gray" className={classes}>
|
||||
<div className={`${baseClass}__graphics`}>{renderGraphics()}</div>
|
||||
<p className={`${baseClass}__message`}>{message}</p>
|
||||
{additionalInfo && (
|
||||
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
|
||||
{isFileSelected && filePreview ? (
|
||||
filePreview
|
||||
) : (
|
||||
<>
|
||||
<div className={`${baseClass}__graphics`}>{renderGraphics()}</div>
|
||||
<p className={`${baseClass}__message`}>{message}</p>
|
||||
{additionalInfo && (
|
||||
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
|
||||
)}
|
||||
<Button
|
||||
className={`${baseClass}__upload-button`}
|
||||
variant={buttonVariant}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<label htmlFor="upload-file">
|
||||
{buttonType === "link" && <Icon name="upload" />}
|
||||
<span>{buttonMessage}</span>
|
||||
</label>
|
||||
</Button>
|
||||
<input
|
||||
accept={accept}
|
||||
id="upload-file"
|
||||
type="file"
|
||||
onChange={onFileSelect}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className={`${baseClass}__upload-button`}
|
||||
variant="brand"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<label htmlFor="upload-file">{buttonMessage}</label>
|
||||
</Button>
|
||||
<input
|
||||
accept={accept}
|
||||
id="upload-file"
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
onFileUpload(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
text-align: center;
|
||||
gap: $pad-small;
|
||||
|
||||
// when the file preview is showing, we want the padding to be
|
||||
// slightly smaller on the top and bottom.
|
||||
&__file-preview {
|
||||
padding: $pad-medium $pad-large;
|
||||
}
|
||||
|
||||
&__graphics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -39,6 +45,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $pad-small;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import React, { ReactNode, useCallback, useRef } from "react";
|
||||
import AceEditor from "react-ace";
|
||||
import ReactAce from "react-ace/lib/ace";
|
||||
import { IAceEditor } from "react-ace/lib/types";
|
||||
@@ -30,10 +30,13 @@ export interface IFleetAceProps {
|
||||
name?: string;
|
||||
value?: string;
|
||||
readOnly?: boolean;
|
||||
maxLines?: number;
|
||||
showGutter?: boolean;
|
||||
wrapEnabled?: boolean;
|
||||
/** @deprecated use the prop `className` instead */
|
||||
wrapperClassName?: string;
|
||||
helpText?: string;
|
||||
className?: string;
|
||||
helpText?: ReactNode;
|
||||
labelActionComponent?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
onBlur?: (editor?: IAceEditor) => void;
|
||||
@@ -53,9 +56,11 @@ const FleetAce = ({
|
||||
name = "query-editor",
|
||||
value,
|
||||
readOnly,
|
||||
maxLines = 20,
|
||||
showGutter = true,
|
||||
wrapEnabled = false,
|
||||
wrapperClassName,
|
||||
className,
|
||||
helpText,
|
||||
style,
|
||||
onBlur,
|
||||
@@ -64,7 +69,7 @@ const FleetAce = ({
|
||||
handleSubmit = noop,
|
||||
}: IFleetAceProps): JSX.Element => {
|
||||
const editorRef = useRef<ReactAce>(null);
|
||||
const wrapperClass = classnames(wrapperClassName, baseClass, {
|
||||
const wrapperClass = classnames(className, wrapperClassName, baseClass, {
|
||||
[`${baseClass}__wrapper--error`]: !!error,
|
||||
});
|
||||
|
||||
@@ -250,7 +255,7 @@ const FleetAce = ({
|
||||
fontSize={fontSize}
|
||||
mode="fleet"
|
||||
minLines={2}
|
||||
maxLines={20}
|
||||
maxLines={maxLines}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
onBlur={onBlurHandler}
|
||||
|
||||
+3
@@ -62,6 +62,9 @@ const FileChooser = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
// TODO: if we reuse this one more time, we should consider moving this
|
||||
// into FileUploader as a default preview. Currently we have this in
|
||||
// AddSoftwareForm.tsx and here.
|
||||
const FileDetails = ({
|
||||
baseClass,
|
||||
details: { name, platform },
|
||||
|
||||
@@ -28,6 +28,7 @@ import TeamsHeader from "components/TeamsHeader";
|
||||
import TabsWrapper from "components/TabsWrapper";
|
||||
|
||||
import ManageAutomationsModal from "./components/ManageSoftwareAutomationsModal";
|
||||
import AddSoftwareModal from "./components/AddSoftwareModal";
|
||||
|
||||
interface ISoftwareSubNavItem {
|
||||
name: string;
|
||||
@@ -110,6 +111,8 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
||||
isGlobalAdmin,
|
||||
isGlobalMaintainer,
|
||||
isOnGlobalTeam,
|
||||
isTeamAdmin,
|
||||
isTeamMaintainer,
|
||||
isPremiumTier,
|
||||
isSandboxMode,
|
||||
} = useContext(AppContext);
|
||||
@@ -142,6 +145,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
||||
);
|
||||
const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
|
||||
const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false);
|
||||
const [showAddSoftwareModal, setShowAddSoftwareModal] = useState(false);
|
||||
|
||||
const {
|
||||
currentTeamId,
|
||||
@@ -218,13 +222,14 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
||||
const isSoftwareConfigLoaded =
|
||||
!isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig;
|
||||
|
||||
const canManageAutomations =
|
||||
isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected);
|
||||
|
||||
const toggleManageAutomationsModal = useCallback(() => {
|
||||
setShowManageAutomationsModal(!showManageAutomationsModal);
|
||||
}, [setShowManageAutomationsModal, showManageAutomationsModal]);
|
||||
|
||||
const toggleAddSoftwareModal = useCallback(() => {
|
||||
setShowAddSoftwareModal(!showAddSoftwareModal);
|
||||
}, [showAddSoftwareModal]);
|
||||
|
||||
const togglePreviewPayloadModal = useCallback(() => {
|
||||
setShowPreviewPayloadModal(!showPreviewPayloadModal);
|
||||
}, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
|
||||
@@ -295,6 +300,35 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderPageActions = () => {
|
||||
const canManageAutomations =
|
||||
isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected);
|
||||
|
||||
const canAddSoftware =
|
||||
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
|
||||
|
||||
if (!isSoftwareConfigLoaded) return null;
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__action-buttons`}>
|
||||
{canManageAutomations && (
|
||||
<Button
|
||||
onClick={toggleManageAutomationsModal}
|
||||
className={`${baseClass}__manage-automations`}
|
||||
variant="text-link"
|
||||
>
|
||||
<span>Manage automations</span>
|
||||
</Button>
|
||||
)}
|
||||
{canAddSoftware && (
|
||||
<Button onClick={toggleAddSoftwareModal} variant="brand">
|
||||
<span>Add software</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeaderDescription = () => {
|
||||
return (
|
||||
<p>
|
||||
@@ -358,15 +392,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
||||
<div className={`${baseClass}__title`}>{renderTitle()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManageAutomations && isSoftwareConfigLoaded && (
|
||||
<Button
|
||||
onClick={toggleManageAutomationsModal}
|
||||
className={`${baseClass}__manage-automations button`}
|
||||
variant="brand"
|
||||
>
|
||||
<span>Manage automations</span>
|
||||
</Button>
|
||||
)}
|
||||
{renderPageActions()}
|
||||
</div>
|
||||
<div className={`${baseClass}__description`}>
|
||||
{renderHeaderDescription()}
|
||||
@@ -386,6 +412,12 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
||||
recentVulnerabilityMaxAge={recentVulnerabilityMaxAge}
|
||||
/>
|
||||
)}
|
||||
{showAddSoftwareModal && (
|
||||
<AddSoftwareModal
|
||||
teamId={currentTeamId ?? 0}
|
||||
onExit={toggleAddSoftwareModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MainContent>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
|
||||
&__text {
|
||||
margin-right: $pad-large;
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import Editor from "components/Editor";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import FleetAce from "components/FleetAce";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
|
||||
const baseClass = "add-software-advanced-options";
|
||||
|
||||
interface IAddSoftwareAdvancedOptionsProps {
|
||||
errors: { preInstallCondition?: string; postInstallScript?: string };
|
||||
showPreInstallCondition: boolean;
|
||||
showPostInstallScript: boolean;
|
||||
preInstallCondition?: string;
|
||||
postInstallScript?: string;
|
||||
onTogglePreInstallCondition: (value: boolean) => void;
|
||||
onTogglePostInstallScript: (value: boolean) => void;
|
||||
onChangePreInstallCondition: (value?: string) => void;
|
||||
onChangePostInstallScript: (value?: string) => void;
|
||||
}
|
||||
|
||||
const AddSoftwareAdvancedOptions = ({
|
||||
errors,
|
||||
showPreInstallCondition,
|
||||
showPostInstallScript,
|
||||
preInstallCondition,
|
||||
postInstallScript,
|
||||
onTogglePreInstallCondition,
|
||||
onTogglePostInstallScript,
|
||||
onChangePreInstallCondition,
|
||||
onChangePostInstallScript,
|
||||
}: IAddSoftwareAdvancedOptionsProps) => {
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
|
||||
const onChangePreInstallCheckbox = () => {
|
||||
onTogglePreInstallCondition(!showPreInstallCondition);
|
||||
};
|
||||
|
||||
const onChangePostInstallCheckbox = () => {
|
||||
onTogglePostInstallScript(!showPostInstallScript);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<RevealButton
|
||||
className={`${baseClass}__accordion-title`}
|
||||
isShowing={showAdvancedOptions}
|
||||
showText="Advanced options"
|
||||
hideText="Advanced options"
|
||||
caretPosition="after"
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
/>
|
||||
{showAdvancedOptions && (
|
||||
<div className={`${baseClass}__input-fields`}>
|
||||
<Checkbox
|
||||
value={showPreInstallCondition}
|
||||
onChange={onChangePreInstallCheckbox}
|
||||
>
|
||||
Pre-install condition
|
||||
</Checkbox>
|
||||
{showPreInstallCondition && (
|
||||
<FleetAce
|
||||
focus
|
||||
error={errors.preInstallCondition}
|
||||
value={preInstallCondition}
|
||||
label="Query"
|
||||
name="preInstallQuery"
|
||||
maxLines={10}
|
||||
onChange={onChangePreInstallCondition}
|
||||
helpText={
|
||||
<>
|
||||
Software will be installed only if the{" "}
|
||||
<CustomLink
|
||||
className={`${baseClass}__table-link`}
|
||||
text="query returns results"
|
||||
url="https://fleetdm.com/tables"
|
||||
newTab
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
value={showPostInstallScript}
|
||||
onChange={onChangePostInstallCheckbox}
|
||||
>
|
||||
Post-install script
|
||||
</Checkbox>
|
||||
{showPostInstallScript && (
|
||||
<>
|
||||
<Editor
|
||||
focus
|
||||
error={errors.postInstallScript}
|
||||
wrapEnabled
|
||||
name="post-install-script-editor"
|
||||
maxLines={10}
|
||||
onChange={onChangePostInstallScript}
|
||||
value={postInstallScript}
|
||||
helpText="Shell (macOS and Linux) or PowerShell (Windows)."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSoftwareAdvancedOptions;
|
||||
@@ -0,0 +1,17 @@
|
||||
.add-software-advanced-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $pad-large;
|
||||
|
||||
&__input-fields {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
|
||||
&__table-link {
|
||||
font-size: $xx-small;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./AddSoftwareAdvancedOptions";
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
// @ts-ignore
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import Spinner from "components/Spinner";
|
||||
import Button from "components/buttons/Button";
|
||||
import FileUploader from "components/FileUploader";
|
||||
import Graphic from "components/Graphic";
|
||||
|
||||
import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions";
|
||||
|
||||
import {
|
||||
generateFormValidation,
|
||||
getFileDetails,
|
||||
getInstallScript,
|
||||
} from "./helpers";
|
||||
|
||||
const baseClass = "add-software-form";
|
||||
|
||||
const UploadingSoftware = () => {
|
||||
return (
|
||||
<div className={`${baseClass}__uploading-message`}>
|
||||
<Spinner centered={false} />
|
||||
<p>Uploading. It may take few minutes to finish.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: if we reuse this one more time, we should consider moving this
|
||||
// into FileUploader as a default preview. Currently we have this in
|
||||
// AddProfileModal.tsx and here.
|
||||
const FileDetails = ({
|
||||
details: { name, platform },
|
||||
}: {
|
||||
details: {
|
||||
name: string;
|
||||
platform: string;
|
||||
};
|
||||
}) => (
|
||||
<div className={`${baseClass}__selected-file`}>
|
||||
<Graphic name="file-pkg" />
|
||||
<div className={`${baseClass}__selected-file--details`}>
|
||||
<div className={`${baseClass}__selected-file--details--name`}>{name}</div>
|
||||
<div className={`${baseClass}__selected-file--details--platform`}>
|
||||
{platform}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface IAddSoftwareFormData {
|
||||
software: File | null;
|
||||
installScript: string;
|
||||
preInstallCondition?: string;
|
||||
postInstallScript?: string;
|
||||
}
|
||||
|
||||
export interface IFormValidation {
|
||||
isValid: boolean;
|
||||
software: { isValid: boolean };
|
||||
preInstallCondition?: { isValid: boolean; message?: string };
|
||||
postInstallScript?: { isValid: boolean; message?: string };
|
||||
}
|
||||
|
||||
interface IAddSoftwareFormProps {
|
||||
isUploading: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (formData: IAddSoftwareFormData) => void;
|
||||
}
|
||||
|
||||
const AddSoftwareForm = ({
|
||||
isUploading,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: IAddSoftwareFormProps) => {
|
||||
console.log("rerender");
|
||||
const [showPreInstallCondition, setShowPreInstallCondition] = useState(false);
|
||||
const [showPostInstallScript, setShowPostInstallScript] = useState(false);
|
||||
const [formData, setFormData] = useState<IAddSoftwareFormData>({
|
||||
software: null,
|
||||
installScript: "",
|
||||
preInstallCondition: undefined,
|
||||
postInstallScript: undefined,
|
||||
});
|
||||
const [formValidation, setFormValidation] = useState<IFormValidation>({
|
||||
isValid: false,
|
||||
software: { isValid: false },
|
||||
});
|
||||
|
||||
const onFileUpload = (files: FileList | null) => {
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
const newData = {
|
||||
...formData,
|
||||
software: file,
|
||||
installScript: getInstallScript(file),
|
||||
};
|
||||
setFormData(newData);
|
||||
setFormValidation(
|
||||
generateFormValidation(
|
||||
newData,
|
||||
showPreInstallCondition,
|
||||
showPostInstallScript
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const onTogglePreInstallConditionCheckbox = (value: boolean) => {
|
||||
const newData = { ...formData, preInstallCondition: undefined };
|
||||
setShowPreInstallCondition(value);
|
||||
setFormData(newData);
|
||||
setFormValidation(
|
||||
generateFormValidation(newData, value, showPostInstallScript)
|
||||
);
|
||||
};
|
||||
|
||||
const onTogglePostInstallScriptCheckbox = (value: boolean) => {
|
||||
const newData = { ...formData, postInstallScript: undefined };
|
||||
setShowPostInstallScript(value);
|
||||
setFormData(newData);
|
||||
setFormValidation(
|
||||
generateFormValidation(newData, showPreInstallCondition, value)
|
||||
);
|
||||
};
|
||||
|
||||
const onChangeInstallScript = (value: string) => {
|
||||
setFormData({ ...formData, installScript: value });
|
||||
};
|
||||
|
||||
const onChangePreInstallCondition = (value?: string) => {
|
||||
const newData = { ...formData, preInstallCondition: value };
|
||||
setFormData(newData);
|
||||
setFormValidation(
|
||||
generateFormValidation(
|
||||
newData,
|
||||
showPreInstallCondition,
|
||||
showPostInstallScript
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onChangePostInstallScript = (value?: string) => {
|
||||
const newData = { ...formData, postInstallScript: value };
|
||||
setFormData(newData);
|
||||
setFormValidation(
|
||||
generateFormValidation(
|
||||
newData,
|
||||
showPreInstallCondition,
|
||||
showPostInstallScript
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const isSubmitDisabled = !formValidation.isValid;
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
{isUploading ? (
|
||||
<UploadingSoftware />
|
||||
) : (
|
||||
<form className={`${baseClass}__form`} onSubmit={onFormSubmit}>
|
||||
<FileUploader
|
||||
graphicName={"file-pkg"}
|
||||
accept=".pkg,.msi,.exe,.deb"
|
||||
message=".pkg, .msi, .exe, or .deb"
|
||||
onFileUpload={onFileUpload}
|
||||
buttonMessage="Choose file"
|
||||
buttonType="link"
|
||||
className={`${baseClass}__file-uploader`}
|
||||
filePreview={
|
||||
formData.software && (
|
||||
<FileDetails details={getFileDetails(formData.software)} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{formData.software && (
|
||||
<InputField
|
||||
value={formData.installScript}
|
||||
onChange={onChangeInstallScript}
|
||||
name="install script"
|
||||
label="Install script"
|
||||
tooltip="For security agents, add the script provided by the vendor."
|
||||
helpText="Fleet will run this command on hosts to install software."
|
||||
/>
|
||||
)}
|
||||
<AddSoftwareAdvancedOptions
|
||||
errors={{
|
||||
preInstallCondition: formValidation.preInstallCondition?.message,
|
||||
postInstallScript: formValidation.postInstallScript?.message,
|
||||
}}
|
||||
showPreInstallCondition={showPreInstallCondition}
|
||||
showPostInstallScript={showPostInstallScript}
|
||||
preInstallCondition={formData.preInstallCondition}
|
||||
postInstallScript={formData.postInstallScript}
|
||||
onTogglePreInstallCondition={onTogglePreInstallConditionCheckbox}
|
||||
onTogglePostInstallScript={onTogglePostInstallScriptCheckbox}
|
||||
onChangePreInstallCondition={onChangePreInstallCondition}
|
||||
onChangePostInstallScript={onChangePostInstallScript}
|
||||
/>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button type="submit" variant="brand" disabled={isSubmitDisabled}>
|
||||
Add software
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="inverse">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSoftwareForm;
|
||||
@@ -0,0 +1,43 @@
|
||||
.add-software-form {
|
||||
|
||||
&__uploading-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
|
||||
p {
|
||||
margin: 0
|
||||
}
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
}
|
||||
|
||||
&__file-uploader {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__selected-file {
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&--details {
|
||||
&--name {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
}
|
||||
|
||||
&--platform {
|
||||
font-size: $xx-small;
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import validator from "validator";
|
||||
|
||||
// @ts-ignore
|
||||
import validateQuery from "components/forms/validators/validate_query";
|
||||
import { getPlatformDisplayName } from "utilities/file/fileUtils";
|
||||
|
||||
import { IAddSoftwareFormData, IFormValidation } from "./AddSoftwareForm";
|
||||
|
||||
type IAddSoftwareFormValidatorKey = Exclude<
|
||||
keyof IAddSoftwareFormData,
|
||||
"installScript"
|
||||
>;
|
||||
|
||||
type IMessageFunc = (formData: IAddSoftwareFormData) => string;
|
||||
type IValidationMessage = string | IMessageFunc;
|
||||
|
||||
interface IValidation {
|
||||
name: string;
|
||||
isValid: (
|
||||
formData: IAddSoftwareFormData,
|
||||
enabledPreInstallCondition?: boolean,
|
||||
enabledPostInstallScript?: boolean
|
||||
) => boolean;
|
||||
message?: IValidationMessage;
|
||||
}
|
||||
|
||||
/** configuration defines validations for each filed in the form. It defines rules
|
||||
* to determine if a field is valid, and rules for generating an error message.
|
||||
*/
|
||||
const FORM_VALIDATION_CONFIG: Record<
|
||||
IAddSoftwareFormValidatorKey,
|
||||
{ validations: IValidation[] }
|
||||
> = {
|
||||
software: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (formData) => formData.software !== null,
|
||||
},
|
||||
],
|
||||
},
|
||||
preInstallCondition: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (
|
||||
formData: IAddSoftwareFormData,
|
||||
enabledPreInstallCondition
|
||||
) => {
|
||||
if (!enabledPreInstallCondition) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
formData.preInstallCondition !== undefined &&
|
||||
!validator.isEmpty(formData.preInstallCondition)
|
||||
);
|
||||
},
|
||||
message: (formData) => {
|
||||
// we dont want an error message until the user has interacted with
|
||||
// the field. This is why we check for undefined here.
|
||||
if (formData.preInstallCondition === undefined) {
|
||||
return "";
|
||||
}
|
||||
return "Pre-install condition is required when enabled.";
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalidQuery",
|
||||
isValid: (formData, enabledPreInstallCondition) => {
|
||||
if (!enabledPreInstallCondition) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
formData.preInstallCondition !== undefined &&
|
||||
validateQuery(formData.preInstallCondition).valid
|
||||
);
|
||||
},
|
||||
message: (formData) =>
|
||||
validateQuery(formData.preInstallCondition).error,
|
||||
},
|
||||
],
|
||||
},
|
||||
postInstallScript: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
message: (formData) => {
|
||||
// we dont want an error message until the user has interacted with
|
||||
// the field. This is why we check for undefined here.
|
||||
if (formData.postInstallScript === undefined) {
|
||||
return "";
|
||||
}
|
||||
return "Post-install script is required when enabled.";
|
||||
},
|
||||
isValid: (formData, _, enabledPostInstallScript) => {
|
||||
if (!enabledPostInstallScript) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
formData.postInstallScript !== undefined &&
|
||||
!validator.isEmpty(formData.postInstallScript)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const getErrorMessage = (
|
||||
formData: IAddSoftwareFormData,
|
||||
message?: IValidationMessage
|
||||
) => {
|
||||
if (message === undefined || typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
return message(formData);
|
||||
};
|
||||
|
||||
export const generateFormValidation = (
|
||||
formData: IAddSoftwareFormData,
|
||||
showingPreInstallCondition: boolean,
|
||||
showingPostInstallScript: boolean
|
||||
) => {
|
||||
const formValidation: IFormValidation = {
|
||||
isValid: true,
|
||||
software: {
|
||||
isValid: false,
|
||||
},
|
||||
};
|
||||
|
||||
Object.keys(FORM_VALIDATION_CONFIG).forEach((key) => {
|
||||
const objKey = key as keyof typeof FORM_VALIDATION_CONFIG;
|
||||
const failedValidation = FORM_VALIDATION_CONFIG[objKey].validations.find(
|
||||
(validation) =>
|
||||
!validation.isValid(
|
||||
formData,
|
||||
showingPreInstallCondition,
|
||||
showingPostInstallScript
|
||||
)
|
||||
);
|
||||
|
||||
if (!failedValidation) {
|
||||
formValidation[objKey] = {
|
||||
isValid: true,
|
||||
};
|
||||
} else {
|
||||
formValidation.isValid = false;
|
||||
formValidation[objKey] = {
|
||||
isValid: false,
|
||||
message: getErrorMessage(formData, failedValidation.message),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return formValidation;
|
||||
};
|
||||
|
||||
export const getFileDetails = (file: File) => {
|
||||
return {
|
||||
name: file.name,
|
||||
platform: getPlatformDisplayName(file),
|
||||
};
|
||||
};
|
||||
|
||||
export const getInstallScript = (file: File) => {
|
||||
// TODO: get this dynamically
|
||||
return `sudo installer -pkg ${file.name} -target /`;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./AddSoftwareForm";
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
|
||||
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import softwareAPI from "services/entities/software";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
import AddSoftwareForm from "../AddSoftwareForm";
|
||||
import { IAddSoftwareFormData } from "../AddSoftwareForm/AddSoftwareForm";
|
||||
|
||||
// 2 minutes
|
||||
const UPLOAD_TIMEOUT = 120000;
|
||||
|
||||
const baseClass = "add-software-modal";
|
||||
|
||||
interface IAllTeamsMessageProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const AllTeamsMessage = ({ onExit }: IAllTeamsMessageProps) => {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
Please select a team first. Software can't be added when{" "}
|
||||
<b>All teams</b> is selected.
|
||||
</p>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button variant="brand" onClick={onExit}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IAddSoftwareModalProps {
|
||||
teamId: number;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const AddSoftwareModal = ({ teamId, onExit }: IAddSoftwareModalProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
// Included for legacy support, e.g. Chrome/Edge < 119
|
||||
e.returnValue = true;
|
||||
};
|
||||
|
||||
// set up event listener to prevent user from leaving page while uploading
|
||||
if (isUploading) {
|
||||
addEventListener("beforeunload", beforeUnloadHandler);
|
||||
timeout = setTimeout(() => {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
}, UPLOAD_TIMEOUT);
|
||||
} else {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
}
|
||||
|
||||
// clean up event listener and timeout on component unmount
|
||||
return () => {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [isUploading]);
|
||||
|
||||
const onAddSoftware = async (formData: IAddSoftwareFormData) => {
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
await softwareAPI.addSoftwarePackage(formData, teamId);
|
||||
renderFlash("success", "Software added successfully!"); // TODO: change message
|
||||
} catch (e) {
|
||||
renderFlash("error", getErrorReason(e));
|
||||
}
|
||||
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add software"
|
||||
onExit={onExit}
|
||||
width="large"
|
||||
className={baseClass}
|
||||
>
|
||||
<>
|
||||
{teamId === APP_CONTEXT_ALL_TEAMS_ID ? (
|
||||
<AllTeamsMessage onExit={onExit} />
|
||||
) : (
|
||||
<AddSoftwareForm
|
||||
isUploading={isUploading}
|
||||
onCancel={onExit}
|
||||
onSubmit={onAddSoftware}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSoftwareModal;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./AddSoftwareModal";
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ISoftwareTitle,
|
||||
} from "interfaces/software";
|
||||
import { buildQueryStringFromParams, QueryParams } from "utilities/url";
|
||||
import { IAddSoftwareFormData } from "pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm";
|
||||
|
||||
export interface ISoftwareApiParams {
|
||||
page?: number;
|
||||
@@ -186,4 +187,23 @@ export default {
|
||||
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
|
||||
addSoftwarePackage: (data: IAddSoftwareFormData, teamId?: number) => {
|
||||
const { SOFTWARE_PACKAGE } = endpoints;
|
||||
|
||||
if (!data.software) {
|
||||
throw new Error("Software package is required");
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("software", data.software);
|
||||
data.installScript && formData.append("install_script", data.installScript);
|
||||
data.preInstallCondition &&
|
||||
formData.append("pre_install_query", data.preInstallCondition);
|
||||
data.postInstallScript &&
|
||||
formData.append("post_install_script", data.postInstallScript);
|
||||
teamId && formData.append("team_id", teamId.toString());
|
||||
|
||||
return sendRequest("POST", SOFTWARE_PACKAGE, formData);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -128,6 +128,7 @@ export default {
|
||||
SOFTWARE_VERSIONS: `/${API_VERSION}/fleet/software/versions`,
|
||||
SOFTWARE_VERSION: (id: number) =>
|
||||
`/${API_VERSION}/fleet/software/versions/${id}`,
|
||||
SOFTWARE_PACKAGE: `/${API_VERSION}/fleet/software/package`,
|
||||
|
||||
SSO: `/v1/fleet/sso`,
|
||||
STATUS_LABEL_COUNTS: `/${API_VERSION}/fleet/host_summary`,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getPlatformDisplayName } from "./fileUtils";
|
||||
|
||||
describe("fileUtils", () => {
|
||||
describe("getPlatformDisplayName", () => {
|
||||
it("should return the correct platform display name depending on the file extension", () => {
|
||||
const file = new File([""], "test.pkg");
|
||||
expect(getPlatformDisplayName(file)).toEqual("macOS");
|
||||
|
||||
const file2 = new File([""], "test.exe");
|
||||
expect(getPlatformDisplayName(file2)).toEqual("Windows");
|
||||
|
||||
const file3 = new File([""], "test.deb");
|
||||
expect(getPlatformDisplayName(file3)).toEqual("linux");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
type IPlatformDisplayName = "macOS" | "Windows" | "linux";
|
||||
|
||||
const getFileExtension = (file: File) => {
|
||||
const nameParts = file.name.split(".");
|
||||
return nameParts.slice(-1)[0];
|
||||
};
|
||||
|
||||
export const FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME: Record<
|
||||
string,
|
||||
IPlatformDisplayName
|
||||
> = {
|
||||
json: "macOS",
|
||||
pkg: "macOS",
|
||||
mobileconfig: "macOS",
|
||||
exe: "Windows",
|
||||
msi: "Windows",
|
||||
xml: "Windows",
|
||||
deb: "linux",
|
||||
};
|
||||
|
||||
/**
|
||||
* This gets the platform display name from the file.
|
||||
*/
|
||||
export const getPlatformDisplayName = (file: File) => {
|
||||
const fileExt = getFileExtension(file);
|
||||
return FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME[fileExt];
|
||||
};
|
||||
Reference in New Issue
Block a user