From 2bae250ff72db4a3cd5f74f07684128128aa17ad Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Fri, 3 May 2024 14:22:20 +0100 Subject: [PATCH] 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 --- changes/issue-18326-ui-add-software | 1 + frontend/components/Editor/Editor.tsx | 102 ++++++++ frontend/components/Editor/_styles.scss | 21 ++ frontend/components/Editor/index.ts | 1 + .../components/FileUploader/FileUploader.tsx | 82 +++++-- frontend/components/FileUploader/_styles.scss | 7 + frontend/components/FleetAce/FleetAce.tsx | 13 +- .../components/AddProfileModal.tsx | 3 + frontend/pages/SoftwarePage/SoftwarePage.tsx | 56 ++++- frontend/pages/SoftwarePage/_styles.scss | 6 + .../AddSoftwareAdvancedOptions.tsx | 110 +++++++++ .../AddSoftwareAdvancedOptions/_styles.scss | 17 ++ .../AddSoftwareAdvancedOptions/index.ts | 1 + .../AddSoftwareForm/AddSoftwareForm.tsx | 220 ++++++++++++++++++ .../components/AddSoftwareForm/_styles.scss | 43 ++++ .../components/AddSoftwareForm/helpers.ts | 168 +++++++++++++ .../components/AddSoftwareForm/index.ts | 1 + .../AddSoftwareModal/AddSoftwareModal.tsx | 109 +++++++++ .../components/AddSoftwareModal/index.ts | 1 + frontend/services/entities/software.ts | 20 ++ frontend/utilities/endpoints.ts | 1 + frontend/utilities/file/fileUtils.tests.ts | 16 ++ frontend/utilities/file/fileUtils.ts | 27 +++ 23 files changed, 986 insertions(+), 40 deletions(-) create mode 100644 changes/issue-18326-ui-add-software create mode 100644 frontend/components/Editor/Editor.tsx create mode 100644 frontend/components/Editor/_styles.scss create mode 100644 frontend/components/Editor/index.ts create mode 100644 frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/AddSoftwareAdvancedOptions.tsx create mode 100644 frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss create mode 100644 frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/index.ts create mode 100644 frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx create mode 100644 frontend/pages/SoftwarePage/components/AddSoftwareForm/_styles.scss create mode 100644 frontend/pages/SoftwarePage/components/AddSoftwareForm/helpers.ts create mode 100644 frontend/pages/SoftwarePage/components/AddSoftwareForm/index.ts create mode 100644 frontend/pages/SoftwarePage/components/AddSoftwareModal/AddSoftwareModal.tsx create mode 100644 frontend/pages/SoftwarePage/components/AddSoftwareModal/index.ts create mode 100644 frontend/utilities/file/fileUtils.tests.ts create mode 100644 frontend/utilities/file/fileUtils.ts 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}

+ )} + + + )} - - { - 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 && ( + + )} + {canAddSoftware && ( + + )} +
+ ); + }; + const renderHeaderDescription = () => { return (

@@ -358,15 +392,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {

{renderTitle()}
- {canManageAutomations && isSoftwareConfigLoaded && ( - - )} + {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 ? ( + + ) : ( +
+ + ) + } + /> + {formData.software && ( + + )} + +
+ + +
+ + )} +
+ ); +}; + +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. +

+
+ +
+ + ); +}; + +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]; +};