diff --git a/changes/issue-18326-ui-add-software b/changes/issue-18326-ui-add-software
new file mode 100644
index 0000000000..297caae502
--- /dev/null
+++ b/changes/issue-18326-ui-add-software
@@ -0,0 +1 @@
+- add ability to upload software from the UI
diff --git a/frontend/components/Editor/Editor.tsx b/frontend/components/Editor/Editor.tsx
new file mode 100644
index 0000000000..4ab4fab32f
--- /dev/null
+++ b/frontend/components/Editor/Editor.tsx
@@ -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
{labelText}
;
+ };
+
+ const renderHelpText = () => {
+ if (helpText) {
+ return {helpText}
;
+ }
+ return null;
+ };
+
+ return (
+
+ {renderLabel()}
+
+ {renderHelpText()}
+
+ );
+};
+
+export default Editor;
diff --git a/frontend/components/Editor/_styles.scss b/frontend/components/Editor/_styles.scss
new file mode 100644
index 0000000000..22fbacfd33
--- /dev/null
+++ b/frontend/components/Editor/_styles.scss
@@ -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;
+ }
+ }
+}
diff --git a/frontend/components/Editor/index.ts b/frontend/components/Editor/index.ts
new file mode 100644
index 0000000000..100d029231
--- /dev/null
+++ b/frontend/components/Editor/index.ts
@@ -0,0 +1 @@
+export { default } from "./Editor";
diff --git a/frontend/components/FileUploader/FileUploader.tsx b/frontend/components/FileUploader/FileUploader.tsx
index 52ab307656..759f9c9501 100644
--- a/frontend/components/FileUploader/FileUploader.tsx
+++ b/frontend/components/FileUploader/FileUploader.tsx
@@ -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) => {
+ const files = e.target.files;
+ onFileUpload(files);
+ setIsFileSelected(true);
+
+ e.target.value = "";
+ };
const renderGraphics = () => {
const graphicNamesArr =
@@ -64,29 +91,36 @@ const FileUploader = ({
/>
));
};
+
return (
- {renderGraphics()}
- {message}
- {additionalInfo && (
- {additionalInfo}
+ {isFileSelected && filePreview ? (
+ filePreview
+ ) : (
+ <>
+ {renderGraphics()}
+ {message}
+ {additionalInfo && (
+ {additionalInfo}
+ )}
+
+
+ {buttonType === "link" && }
+ {buttonMessage}
+
+
+
+ >
)}
-
- {buttonMessage}
-
- {
- onFileUpload(e.target.files);
- e.target.value = "";
- }}
- />
);
};
diff --git a/frontend/components/FileUploader/_styles.scss b/frontend/components/FileUploader/_styles.scss
index 4a6835d3ec..4cb2e24539 100644
--- a/frontend/components/FileUploader/_styles.scss
+++ b/frontend/components/FileUploader/_styles.scss
@@ -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;
diff --git a/frontend/components/FleetAce/FleetAce.tsx b/frontend/components/FleetAce/FleetAce.tsx
index 5a6f6ba7bc..c30232cb59 100644
--- a/frontend/components/FleetAce/FleetAce.tsx
+++ b/frontend/components/FleetAce/FleetAce.tsx
@@ -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(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}
diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal.tsx
index 403ef83c16..b948e9d9dd 100644
--- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal.tsx
+++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal.tsx
@@ -62,6 +62,9 @@ const FileChooser = ({
);
+// 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 },
diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx
index 20ccb325e4..89020ca581 100644
--- a/frontend/pages/SoftwarePage/SoftwarePage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx
@@ -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 (
+
+ {canManageAutomations && (
+
+ Manage automations
+
+ )}
+ {canAddSoftware && (
+
+ Add software
+
+ )}
+
+ );
+ };
+
const renderHeaderDescription = () => {
return (
@@ -358,15 +392,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
{renderTitle()}
- {canManageAutomations && isSoftwareConfigLoaded && (
-
- Manage automations
-
- )}
+ {renderPageActions()}
{renderHeaderDescription()}
@@ -386,6 +412,12 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
recentVulnerabilityMaxAge={recentVulnerabilityMaxAge}
/>
)}
+ {showAddSoftwareModal && (
+
+ )}
);
diff --git a/frontend/pages/SoftwarePage/_styles.scss b/frontend/pages/SoftwarePage/_styles.scss
index cfdd0b9c51..2740a08de4 100644
--- a/frontend/pages/SoftwarePage/_styles.scss
+++ b/frontend/pages/SoftwarePage/_styles.scss
@@ -25,6 +25,12 @@
}
}
+ &__action-buttons {
+ display: flex;
+ align-items: center;
+ gap: $pad-medium;
+ }
+
&__text {
margin-right: $pad-large;
}
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/AddSoftwareAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/AddSoftwareAdvancedOptions.tsx
new file mode 100644
index 0000000000..96de54fd33
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/AddSoftwareAdvancedOptions.tsx
@@ -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 (
+
+
setShowAdvancedOptions(!showAdvancedOptions)}
+ />
+ {showAdvancedOptions && (
+
+
+ Pre-install condition
+
+ {showPreInstallCondition && (
+
+ Software will be installed only if the{" "}
+
+ >
+ }
+ />
+ )}
+
+ Post-install script
+
+ {showPostInstallScript && (
+ <>
+
+ >
+ )}
+
+ )}
+
+ );
+};
+
+export default AddSoftwareAdvancedOptions;
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss b/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss
new file mode 100644
index 0000000000..58f1f85892
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss
@@ -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;
+ }
+}
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/index.ts b/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/index.ts
new file mode 100644
index 0000000000..264fa61b11
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/index.ts
@@ -0,0 +1 @@
+export { default } from "./AddSoftwareAdvancedOptions";
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx b/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx
new file mode 100644
index 0000000000..5c619e9687
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx
@@ -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 (
+
+
+
Uploading. It may take few minutes to finish.
+
+ );
+};
+
+// 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;
+ };
+}) => (
+
+
![]()
+
+
{name}
+
+ {platform}
+
+
+
+);
+
+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({
+ software: null,
+ installScript: "",
+ preInstallCondition: undefined,
+ postInstallScript: undefined,
+ });
+ const [formValidation, setFormValidation] = useState({
+ 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) => {
+ 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 (
+
+ {isUploading ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default AddSoftwareForm;
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareForm/_styles.scss b/frontend/pages/SoftwarePage/components/AddSoftwareForm/_styles.scss
new file mode 100644
index 0000000000..d955a1df9c
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddSoftwareForm/_styles.scss
@@ -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;
+ }
+ }
+ }
+}
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareForm/helpers.ts b/frontend/pages/SoftwarePage/components/AddSoftwareForm/helpers.ts
new file mode 100644
index 0000000000..3464a83153
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddSoftwareForm/helpers.ts
@@ -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 /`;
+};
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareForm/index.ts b/frontend/pages/SoftwarePage/components/AddSoftwareForm/index.ts
new file mode 100644
index 0000000000..d3ea76d47d
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddSoftwareForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./AddSoftwareForm";
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareModal/AddSoftwareModal.tsx b/frontend/pages/SoftwarePage/components/AddSoftwareModal/AddSoftwareModal.tsx
new file mode 100644
index 0000000000..e2233964d6
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddSoftwareModal/AddSoftwareModal.tsx
@@ -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 (
+ <>
+
+ Please select a team first. Software can't be added when{" "}
+ All teams is selected.
+
+
+
+ Done
+
+
+ >
+ );
+};
+
+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 (
+
+ <>
+ {teamId === APP_CONTEXT_ALL_TEAMS_ID ? (
+
+ ) : (
+
+ )}
+ >
+
+ );
+};
+
+export default AddSoftwareModal;
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareModal/index.ts b/frontend/pages/SoftwarePage/components/AddSoftwareModal/index.ts
new file mode 100644
index 0000000000..d8ac7200d6
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddSoftwareModal/index.ts
@@ -0,0 +1 @@
+export { default } from "./AddSoftwareModal";
diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts
index 3643bab792..3cadcdfd3d 100644
--- a/frontend/services/entities/software.ts
+++ b/frontend/services/entities/software.ts
@@ -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);
+ },
};
diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts
index 5b82ebfbe2..07cc0b3245 100644
--- a/frontend/utilities/endpoints.ts
+++ b/frontend/utilities/endpoints.ts
@@ -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`,
diff --git a/frontend/utilities/file/fileUtils.tests.ts b/frontend/utilities/file/fileUtils.tests.ts
new file mode 100644
index 0000000000..8d65063892
--- /dev/null
+++ b/frontend/utilities/file/fileUtils.tests.ts
@@ -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");
+ });
+ });
+});
diff --git a/frontend/utilities/file/fileUtils.ts b/frontend/utilities/file/fileUtils.ts
new file mode 100644
index 0000000000..9cecb327a1
--- /dev/null
+++ b/frontend/utilities/file/fileUtils.ts
@@ -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];
+};