fleet/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx
jacobshandling 5d9026b7e5
UI - GitOps Mode: Core abstractions, first batch of applications (#26401)
## For #26229 – Part 1


![ezgif-6bbe6d60c12ed4](https://github.com/user-attachments/assets/37a04b64-abd7-4605-b4ac-9542836ff562)

- This PR contains the core abstractions, routes, API updates, and types
for GitOps mode in the UI. Since this work will touch essentially every
part of the Fleet UI, it is ripe for merge conflicts. To mitigate such
conflicts, I'll be merging this work in a number of iterative PRs. ~To
effectively gate any of this work from showing until it is all merged to
`main`, [this commit](feedbb2d4c) hides
the settings section that allows enabling/disabling this setting,
effectively feature flagging the entire thing. In the last of these
iterative PRs, that commit will be reverted to engage the entire
feature. For testing purposes, reviewers can `git revert
feedbb2d4c25ec2e304e1f18d409cee62f6752ed` locally~ The new settings
section for this feature is feature flagged until all PRs are merged -
to show the setting section while testing, run `ALLOW_GITOPS_MODE=true
NODE_ENV=development yarn run webpack --progress --watch` in place of
`make generate-dev`

- Changes file will be added and feature flag removed in the last PR

- [x] Settings page with routing, form, API integration (hidden until
last PR)
- [x] Activities
- [x] Navbar indicator
- Apply GOM conditional UI to:
    - [x] Manage enroll secret modal: .5
    -  Controls >
        - [x] Scripts:
        - Setup experience > 
            - [x] Install software > Select software modal
        - [x] OS Settings >
            - [x] Custom settings
            - [x] Disk encryption
        - [x] OS Updates
 
2/18/25, added to this PR:

   - [x] Controls > Setup experience > Run script
   - [x] Software >
        - [x] Manage automations modal
        - [x] Add software >
            - [x] App Store (VPP)
            - [x] Custom package
   - [x] Queries
        - [x] Manage
        - [x] Automations modal
        - [x] New
        - [x] Edit
   - [x] Policies
     - [x] Manage
     - [x] New
     - [x] Edit
     -  Manage automations
       - [x] Calendar events


- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
2025-02-20 08:41:07 -08:00

337 lines
11 KiB
TypeScript

// Used in AddPackageModal.tsx and EditSoftwareModal.tsx
import React, { useContext, useState, useEffect, useCallback } from "react";
import classnames from "classnames";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { getFileDetails } from "utilities/file/fileUtils";
import getDefaultInstallScript from "utilities/software_install_scripts";
import getDefaultUninstallScript from "utilities/software_uninstall_scripts";
import { ILabelSummary } from "interfaces/label";
import { PackageType } from "interfaces/package_type";
import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox";
import FileUploader from "components/FileUploader";
import TooltipWrapper from "components/TooltipWrapper";
import {
CUSTOM_TARGET_OPTIONS,
generateHelpText,
generateSelectedLabels,
getCustomTarget,
getTargetType,
InstallType,
InstallTypeSection,
} from "pages/SoftwarePage/helpers";
import TargetLabelSelector from "components/TargetLabelSelector";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import PackageAdvancedOptions from "../PackageAdvancedOptions";
import { generateFormValidation } from "./helpers";
export const baseClass = "package-form";
export interface IPackageFormData {
software: File | null;
preInstallQuery?: string;
installScript: string;
postInstallScript?: string;
uninstallScript?: string;
selfService: boolean;
targetType: string;
customTarget: string;
labelTargets: Record<string, boolean>;
installType: InstallType; // Used on add but not edit
}
export interface IPackageFormValidation {
isValid: boolean;
software: { isValid: boolean };
preInstallQuery?: { isValid: boolean; message?: string };
customTarget?: { isValid: boolean };
}
interface IPackageFormProps {
labels: ILabelSummary[];
showSchemaButton?: boolean;
onCancel: () => void;
onSubmit: (formData: IPackageFormData) => void;
onClickShowSchema?: () => void;
isEditingSoftware?: boolean;
defaultSoftware?: any; // TODO
defaultInstallScript?: string;
defaultPreInstallQuery?: string;
defaultPostInstallScript?: string;
defaultUninstallScript?: string;
defaultSelfService?: boolean;
className?: string;
/** Indicates that this PackageFOrm deals with an entity that can be managed by GitOps, and so should be disabled when gitops mode is enabled */
gitopsCompatible?: boolean;
}
const ACCEPTED_EXTENSIONS = ".pkg,.msi,.exe,.deb,.rpm";
const PackageForm = ({
labels,
showSchemaButton = false,
onClickShowSchema,
onCancel,
onSubmit,
isEditingSoftware = false,
defaultSoftware,
defaultInstallScript,
defaultPreInstallQuery,
defaultPostInstallScript,
defaultUninstallScript,
defaultSelfService,
className,
gitopsCompatible = false,
}: IPackageFormProps) => {
const { renderFlash } = useContext(NotificationContext);
const gomEnabled = useContext(AppContext).config?.gitops.gitops_mode_enabled;
const initialFormData: IPackageFormData = {
software: defaultSoftware || null,
installScript: defaultInstallScript || "",
preInstallQuery: defaultPreInstallQuery || "",
postInstallScript: defaultPostInstallScript || "",
uninstallScript: defaultUninstallScript || "",
selfService: defaultSelfService || false,
targetType: getTargetType(defaultSoftware),
customTarget: getCustomTarget(defaultSoftware),
labelTargets: generateSelectedLabels(defaultSoftware),
installType: "manual",
};
const [formData, setFormData] = useState<IPackageFormData>(initialFormData);
const [formValidation, setFormValidation] = useState<IPackageFormValidation>({
isValid: false,
software: { isValid: false },
});
const onFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const file = files[0];
// Only populate default install/uninstall scripts when adding (but not editing) software
if (isEditingSoftware) {
const newData = { ...formData, software: file };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
} else {
let newDefaultInstallScript: string;
try {
newDefaultInstallScript = getDefaultInstallScript(file.name);
} catch (e) {
renderFlash("error", `${e}`);
return;
}
let newDefaultUninstallScript: string;
try {
newDefaultUninstallScript = getDefaultUninstallScript(file.name);
} catch (e) {
renderFlash("error", `${e}`);
return;
}
const newData = {
...formData,
software: file,
installScript: newDefaultInstallScript || "",
uninstallScript: newDefaultUninstallScript || "",
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
}
}
};
const onFormSubmit = (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
onSubmit(formData);
};
const onChangeInstallScript = (value: string) => {
const newData = { ...formData, installScript: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onChangePreInstallQuery = (value?: string) => {
const newData = { ...formData, preInstallQuery: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onChangePostInstallScript = (value?: string) => {
const newData = { ...formData, postInstallScript: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onChangeUninstallScript = (value?: string) => {
const newData = { ...formData, uninstallScript: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onChangeInstallType = useCallback(
(value: string) => {
const installType = value as InstallType;
const newData = { ...formData, installType };
setFormData(newData);
},
[formData]
);
const onToggleSelfServiceCheckbox = (value: boolean) => {
const newData = { ...formData, selfService: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onSelectTargetType = (value: string) => {
const newData = { ...formData, targetType: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onSelectCustomTarget = (value: string) => {
const newData = { ...formData, customTarget: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => {
const newData = {
...formData,
labelTargets: { ...formData.labelTargets, [name]: value },
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const isSubmitDisabled = !formValidation.isValid;
const classNames = classnames(baseClass, className);
const ext = formData?.software?.name.split(".").pop() as PackageType;
const isExePackage = ext === "exe";
// If a user preselects automatic install and then uploads a .exe
// which automatic install is not supported, the form will default
// back to manual install
useEffect(() => {
if (isExePackage && formData.installType === "automatic") {
onChangeInstallType("manual");
}
}, [formData.installType, isExePackage, onChangeInstallType]);
return (
<div className={classNames}>
<form className={`${baseClass}__form`} onSubmit={onFormSubmit}>
<FileUploader
canEdit={isEditingSoftware}
graphicName={"file-pkg"}
accept={ACCEPTED_EXTENSIONS}
message=".pkg, .msi, .exe, .deb, or .rpm"
onFileUpload={onFileSelect}
buttonMessage="Choose file"
buttonType="link"
className={`${baseClass}__file-uploader`}
fileDetails={
formData.software ? getFileDetails(formData.software) : undefined
}
gitopsCompatible={gitopsCompatible}
/>
<div
// including `form` class here keeps the children fields subject to the global form
// children styles
className={
gitopsCompatible && gomEnabled
? `${baseClass}__form-fields--gitops-disabled form`
: "form"
}
>
{!isEditingSoftware && (
<InstallTypeSection
className={`${baseClass}__install-type`}
isCustomPackage
isExeCustomPackage={isExePackage}
installType={formData.installType}
onChangeInstallType={onChangeInstallType}
/>
)}
<TargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
className={`${baseClass}__target`}
onSelectTargetType={onSelectTargetType}
onSelectCustomTarget={onSelectCustomTarget}
onSelectLabel={onSelectLabel}
labels={labels || []}
dropdownHelpText={
formData.targetType === "Custom" &&
generateHelpText(formData.installType, formData.customTarget)
}
/>
<Checkbox
value={formData.selfService}
onChange={onToggleSelfServiceCheckbox}
>
<TooltipWrapper
tipContent={
<>
End users can install from{" "}
<b>Fleet Desktop {">"} Self-service</b>.
</>
}
>
Self-service
</TooltipWrapper>
</Checkbox>
<PackageAdvancedOptions
showSchemaButton={showSchemaButton}
selectedPackage={formData.software}
errors={{
preInstallQuery: formValidation.preInstallQuery?.message,
}}
preInstallQuery={formData.preInstallQuery}
installScript={formData.installScript}
postInstallScript={formData.postInstallScript}
uninstallScript={formData.uninstallScript}
onClickShowSchema={onClickShowSchema}
onChangePreInstallQuery={onChangePreInstallQuery}
onChangeInstallScript={onChangeInstallScript}
onChangePostInstallScript={onChangePostInstallScript}
onChangeUninstallScript={onChangeUninstallScript}
/>
</div>
<div className="form-buttons">
<GitOpsModeTooltipWrapper
tipOffset={6}
renderChildren={(dC) => (
<Button
type="submit"
variant="brand"
disabled={dC || isSubmitDisabled}
>
{isEditingSoftware ? "Save" : "Add software"}
</Button>
)}
/>
<Button onClick={onCancel} variant="inverse">
Cancel
</Button>
</div>
</form>
</div>
);
};
// Allows form not to re-render as long as its props don't change
export default React.memo(PackageForm);