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:
Gabriel Hernandez
2024-05-03 14:22:20 +01:00
committed by GitHub
parent 5d2d94401e
commit 2bae250ff7
23 changed files with 986 additions and 40 deletions
+1
View File
@@ -0,0 +1 @@
- add ability to upload software from the UI
+102
View File
@@ -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;
+21
View File
@@ -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;
}
}
}
+1
View File
@@ -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;
+9 -4
View File
@@ -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}
@@ -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 },
+44 -12
View File
@@ -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>
);
+6
View File
@@ -25,6 +25,12 @@
}
}
&__action-buttons {
display: flex;
align-items: center;
gap: $pad-medium;
}
&__text {
margin-right: $pad-large;
}
@@ -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&apos;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";
+20
View File
@@ -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);
},
};
+1
View File
@@ -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");
});
});
});
+27
View File
@@ -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];
};