fleet/frontend/components/FileUploader/FileUploader.tsx
Scott Gress 81f589d661
Update add script UI (#34349)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #32632

# Details

This PR updates the Script Library page in the following ways:
* When no scripts are uploaded for a team, it shows the "Add script" UI
with a button that opens a new "Add Script" modal
* When scripts are uploaded, the "Add script" button is instead added to
the header of the scripts list, and clicking it opens that modal

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [ ] Added/updated automated tests
working on this

- [X] QA'd all new/changed functionality manually
- [X] Test empty state: go to controls/scripts/library for a team with
no scripts. Clicking "upload" button in empty state should open the add
script modal.
- [X] In the modal, select a .ps1 script. Should not see additional
text.
- [X] Close modal without uploading. Re-open. File field should be
cleared & upload button visible again.
- [X] Select a .sh script. Should see additional text about macOS and
Linux.
  - [X] Add script. Make sure script saves and modal closes.
- [X] Once script has been added, make sure empty state is gone and "Add
script" button is at the top of the list.
- [X] Go to /controls/os-settings/custom-settings for a team with no
profiles uploaded. Make sure empty state text styles match the empty
state for script uploads.
- [X] Open modal to add profile. Make sure upload text styles match the
script upload modal.
- [X] Enable GitOps mode. Go to controls/scripts/library for a team with
scripts added. Make sure new "Add script" button is disabled w/ standard
tooltip in GitOps mode.

Scripts empty state:

<img width="697" height="352" alt="image"
src="https://github.com/user-attachments/assets/32f0f246-bddb-4bb7-bc39-48d9978de9fa"
/>

Scripts uploader:

<img width="745" height="590" alt="image"
src="https://github.com/user-attachments/assets/f82414e2-9318-4543-b5ca-41e759662587"
/>

Scripts uploader with .sh

<img width="750" height="539" alt="image"
src="https://github.com/user-attachments/assets/0b989067-921a-4d18-93ed-09aac90fc9cb"
/>

Scripts table:

<img width="686" height="256" alt="image"
src="https://github.com/user-attachments/assets/848f1b56-6e9e-48d4-9a03-6fdf5427301e"
/>

Profiles empty state:

<img width="700" height="377" alt="image"
src="https://github.com/user-attachments/assets/8f92bcd9-2215-41f6-a540-4774f7e9542b"
/>

Profiles uploader:

<img width="707" height="682" alt="image"
src="https://github.com/user-attachments/assets/eef216af-3447-48e7-882a-e42e888e1c17"
/>
2025-10-17 10:49:59 -05:00

271 lines
8 KiB
TypeScript

import React, { useState, useRef } from "react";
import classnames from "classnames";
import Button from "components/buttons/Button";
import Card from "components/Card";
import { GraphicNames } from "components/graphics";
import Icon from "components/Icon";
import Graphic from "components/Graphic";
import FileDetails from "components/FileDetails";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "file-uploader";
export type ISupportedGraphicNames = Extract<
GraphicNames,
| "file-configuration-profile"
| "file-sh"
| "file-ps1"
| "file-py"
| "file-script"
| "file-pdf"
| "file-pkg"
| "file-p7m"
| "file-pem"
| "file-vpp"
| "file-png"
>;
interface IFileUploaderProps {
graphicName: ISupportedGraphicNames | ISupportedGraphicNames[];
message: string;
title?: string;
additionalInfo?: string;
/** Controls the loading spinner on the upload button */
isLoading?: boolean;
/** Disables the upload button */
disabled?: boolean;
/** A comma separated string of one or more file types accepted to upload.
* This is the same as the html accept attribute.
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
*/
accept?: string;
/** 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" | "brand-inverse-icon";
/** renders a tooltip for the button. If `gitopsCompatible` is set to `true`
* this tooltip will not be rendered if gitops mode is enabled. */
buttonTooltip?: React.ReactNode;
onFileUpload: (files: FileList | null) => void;
/** renders the current file with the edit pencil button */
canEdit?: boolean;
/** renders the current file with the delete trash button */
onDeleteFile?: () => void;
/** if provided, will be called when the button is clicked
* instead of opening the file selector. Useful if you want to
* show the file selector UI but handle the file selection
* in a modal.
*/
onButtonClick?: () => void;
fileDetails?: {
name: string;
description?: string;
};
/** Indicates that this file uploader deals with an entity that can be managed by GitOps, and so should be disabled when gitops mode is enabled */
gitopsCompatible?: boolean;
/** Whether or not GitOpsMode is enabled. Has no effect if `gitopsCompatible` is false */
gitOpsModeEnabled?: boolean;
}
/**
* A component that encapsulates the UI for uploading a file and a file selected.
*/
export const FileUploader = ({
graphicName: graphicNames,
message,
title,
additionalInfo,
isLoading = false,
disabled = false,
accept,
className,
buttonMessage = "Upload",
buttonType = "button",
buttonTooltip,
onButtonClick,
onFileUpload,
canEdit = false,
onDeleteFile,
fileDetails,
gitopsCompatible = false,
gitOpsModeEnabled = false,
}: IFileUploaderProps) => {
const [isFileSelected, setIsFileSelected] = useState(!!fileDetails);
const fileInputRef = useRef<HTMLInputElement>(null);
const classes = classnames(baseClass, className, {
[`${baseClass}__file-preview`]: isFileSelected,
});
const buttonVariant =
buttonType === "button" ? "default" : "brand-inverse-icon";
const triggerFileInput = () => {
fileInputRef.current?.click();
};
const onFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const target = e.currentTarget;
// Ensure target is the expected input element to prevent DOM manipulation
if (target && target.type === "file") {
const files = target.files;
onFileUpload(files);
setIsFileSelected(true);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
triggerFileInput();
}
};
const renderGraphics = () => {
const graphicNamesArr =
typeof graphicNames === "string" ? [graphicNames] : graphicNames;
return graphicNamesArr.map((graphicName) => (
<Graphic
key={`${graphicName}-graphic`}
className={`${baseClass}__graphic`}
name={graphicName}
/>
));
};
const renderUploadButton = () => {
let buttonMarkup = (
<>
{buttonType === "brand-inverse-icon" && (
<Icon color="core-fleet-green" name="upload" />
)}
<span>{buttonMessage}</span>
</>
);
// If we want to actual do file uploading, wrap in a label that
// references the hidden file input. Otherwise just use a span.
if (!onButtonClick) {
buttonMarkup = <label htmlFor="upload-file">{buttonMarkup}</label>;
} else {
buttonMarkup = <span>{buttonMarkup}</span>;
}
// the gitops mode tooltip wrapper takes presedence over other button
// renderings
if (gitopsCompatible) {
return (
<GitOpsModeTooltipWrapper
tipOffset={8}
renderChildren={(disableChildren) => (
<TooltipWrapper
className={`${baseClass}__manual-install-tooltip`}
tipContent={buttonTooltip}
disableTooltip={disableChildren || !buttonTooltip}
position="top"
showArrow
underline={false}
>
<Button
className={`${baseClass}__upload-button`}
variant={buttonVariant}
isLoading={isLoading}
disabled={disabled || disableChildren}
customOnKeyDown={!onButtonClick ? handleKeyDown : undefined}
onClick={onButtonClick || undefined}
tabIndex={0}
>
{buttonMarkup}
</Button>
</TooltipWrapper>
)}
/>
);
}
return (
<TooltipWrapper
className={`${baseClass}__upload-button`}
position="top"
tipContent={buttonTooltip}
underline={false}
showArrow
disableTooltip={!buttonTooltip}
>
<Button
className={`${baseClass}__upload-button`}
variant={buttonVariant}
isLoading={isLoading}
disabled={disabled}
customOnKeyDown={!onButtonClick ? handleKeyDown : undefined}
onClick={onButtonClick || undefined}
tabIndex={0}
>
<label htmlFor="upload-file">{buttonMarkup}</label>
</Button>
</TooltipWrapper>
);
};
const renderFileUploader = () => {
return (
<>
<div className="content-wrapper">
<div className="outer">
<div className="inner">
<div className={`${baseClass}__graphics`}>{renderGraphics()}</div>
{title && <div className={`${baseClass}__title`}>{title}</div>}
<p className={`${baseClass}__message`}>{message}</p>
{additionalInfo && (
<p className={`${baseClass}__additional-info`}>
{additionalInfo}
</p>
)}
</div>
{renderUploadButton()}
{/* If onButtonClick is provided, we're not actually uploading files here. */}
{!onButtonClick && (
<input
ref={fileInputRef}
accept={accept}
id="upload-file"
type="file"
onChange={onFileSelect}
/>
)}
</div>
</div>
</>
);
};
return (
<Card color="grey" className={classes}>
{fileDetails ? (
<FileDetails
graphicNames={graphicNames}
fileDetails={fileDetails}
canEdit={canEdit}
onDeleteFile={onDeleteFile}
onFileSelect={onFileSelect}
accept={accept}
gitopsCompatible={gitopsCompatible}
gitOpsModeEnabled={gitOpsModeEnabled}
/>
) : (
renderFileUploader()
)}
</Card>
);
};
export default FileUploader;