mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #36804 - Update text/icon order when relevant, remove redundant `label` causing padding inconsistencies <img width="965" height="1521" alt="Screenshot 2025-12-05 at 2 10 14 PM" src="https://github.com/user-attachments/assets/fb8df8f4-f98a-4a26-8c82-b846576529a9" /> - Confirm UI/UX everywhere it's used: <img width="619" height="553" alt="Screenshot 2025-12-05 at 2 23 23 PM" src="https://github.com/user-attachments/assets/87295511-e84b-4f68-8403-2fb1dc1c7ccf" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 23 06 PM" src="https://github.com/user-attachments/assets/e6f995af-ba77-477d-84ad-0acc4104314e" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 19 59 PM" src="https://github.com/user-attachments/assets/20bd6cd9-2340-4dbb-a9cc-8c46fe64a847" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 20 25 PM" src="https://github.com/user-attachments/assets/62c8694a-2380-47b9-b59b-6878a4f49d8e" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 21 19 PM" src="https://github.com/user-attachments/assets/7ec9487a-3387-4060-aebb-421c5e878329" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 17 29 PM" src="https://github.com/user-attachments/assets/f8509f38-a143-4a96-84f3-3c791cd5177c" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 17 15 PM" src="https://github.com/user-attachments/assets/acc42d69-8c79-4a11-a0eb-fadf4dc10523" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 16 50 PM" src="https://github.com/user-attachments/assets/eea89d0d-648c-4d1b-94e8-cba0226200fc" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 21 35 PM" src="https://github.com/user-attachments/assets/36dc034d-dfa8-4dd1-8b76-a282e4e52aca" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 17 45 PM" src="https://github.com/user-attachments/assets/a7050ad9-c0a2-42e0-a76f-15b9bb171d8b" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 22 45 PM" src="https://github.com/user-attachments/assets/cb3a0d7c-270a-46aa-ae6a-e2695e41c26a" /> <img width="1464" height="959" alt="Screenshot 2025-12-05 at 2 19 14 PM" src="https://github.com/user-attachments/assets/982a072c-2523-4bf5-b67a-82506ac844cc" /> # 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/` - [x] QA'd all new/changed functionality manually
295 lines
8.6 KiB
TypeScript
295 lines
8.6 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 {
|
|
label?: React.ReactNode;
|
|
graphicName: ISupportedGraphicNames | ISupportedGraphicNames[];
|
|
message: React.ReactNode;
|
|
title?: string;
|
|
/** allow error state within the file uploader, as opposed to on its label */
|
|
internalError?: 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?: React.ReactNode;
|
|
};
|
|
/** 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 = ({
|
|
label,
|
|
graphicName: graphicNames,
|
|
message,
|
|
title,
|
|
internalError,
|
|
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,
|
|
[`${baseClass}__error`]: !!internalError,
|
|
});
|
|
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 renderLabel = () => {
|
|
return label ? (
|
|
<div className={`${baseClass}__label form-field__label`}>{label}</div>
|
|
) : null;
|
|
};
|
|
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 = (
|
|
<>
|
|
{buttonMessage}
|
|
{buttonType === "brand-inverse-icon" && (
|
|
<Icon color="core-fleet-green" name="upload" />
|
|
)}
|
|
</>
|
|
);
|
|
// 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}
|
|
>
|
|
{buttonMarkup}
|
|
</Button>
|
|
</TooltipWrapper>
|
|
);
|
|
};
|
|
|
|
const renderTitle = () => {
|
|
if (internalError) {
|
|
return (
|
|
<div className={`${baseClass}__internal-error`}>{internalError}</div>
|
|
);
|
|
}
|
|
if (title) {
|
|
return <div className={`${baseClass}__title`}>{title}</div>;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderFileUploader = () => {
|
|
return (
|
|
<div className="content-wrapper">
|
|
<div className="outer">
|
|
<div className="inner">
|
|
<div className={`${baseClass}__graphics`}>{renderGraphics()}</div>
|
|
{renderTitle()}
|
|
<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 (
|
|
<div className={`${baseClass}__wrapper form-field`}>
|
|
{renderLabel()}
|
|
<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>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FileUploader;
|