Create new ListItem and FileUploader component and use on controls page (#15103)

This adds a new ListItem component and FileUploader component and
updates the Custom settings and scripts page to use this new ListItem
component.

This List component centralises where the markup and styles live. We
still need to update the bootstrap list item and eula upload list item
but will do that in the future.

- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez
2023-11-13 15:30:22 -08:00
committed by GitHub
parent 4cf682d78f
commit da20214938
24 changed files with 393 additions and 226 deletions
@@ -0,0 +1,24 @@
import { Meta, StoryObj } from "@storybook/react";
import FileUploader from "./FileUploader";
const meta: Meta<typeof FileUploader> = {
title: "Components/FileUploader",
component: FileUploader,
args: {
graphicName: "file-configuration-profile",
message: "The main message",
additionalInfo: "The additional message",
accept: ".pdf",
isLoading: false,
onFileUpload: () => {
alert("File uploaded!");
},
},
};
export default meta;
type Story = StoryObj<typeof FileUploader>;
export const Basic: Story = {};
@@ -8,10 +8,23 @@ import Graphic from "components/Graphic";
const baseClass = "file-uploader";
type ISupportedGraphicNames = Extract<
GraphicNames,
| "file-configuration-profile"
| "file-sh"
| "file-py"
| "file-script"
| "file-pdf"
| "file-pkg"
| "file-p7m"
| "file-pem"
>;
interface IFileUploaderProps {
graphicName: GraphicNames;
graphicName: ISupportedGraphicNames;
message: string;
additionalInfo?: string;
/** Controls the loading spinner on the upload button */
isLoading?: boolean;
/** A comma seperated string of one or more file types accepted to upload.
* This is the same as the html accept attribute.
@@ -22,6 +35,9 @@ interface IFileUploaderProps {
onFileUpload: (files: FileList | null) => void;
}
/**
* A component that encapsulates the UI for uploading a file.
*/
const FileUploader = ({
graphicName,
message,
@@ -37,7 +53,9 @@ const FileUploader = ({
<Card color="gray" className={classes}>
<Graphic name={graphicName} />
<p className={`${baseClass}__message`}>{message}</p>
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
{additionalInfo && (
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
)}
<Button
className={`${baseClass}__upload-button`}
variant="brand"
+6
View File
@@ -0,0 +1,6 @@
.graphic {
// keeps this element the same size as the svg
// aligns properly in text, buttons, dropdowns, summary tile custom component
display: inline-flex;
align-self: center;
}
@@ -0,0 +1,34 @@
import React from "react";
import { Meta, StoryObj } from "@storybook/react";
import Button from "components/buttons/Button";
import ListItem from "./ListItem";
const meta: Meta<typeof ListItem> = {
title: "Components/ListItem",
component: ListItem,
args: {
graphic: "file-configuration-profile",
title: "List Item Title",
details: (
<>
<span>Details </span>
<span> &bull; </span>
<span> more details</span>
</>
),
actions: (
<>
<Button>button 1</Button>
<Button>Button 2</Button>
</>
),
},
};
export default meta;
type Story = StoryObj<typeof ListItem>;
export const Basic: Story = {};
+60
View File
@@ -0,0 +1,60 @@
import React from "react";
import classnames from "classnames";
import Graphic from "components/Graphic";
import { GraphicNames } from "components/graphics";
const baseClass = "list-item";
type ISupportedGraphicNames = Extract<
GraphicNames,
| "file-configuration-profile"
| "file-sh"
| "file-py"
| "file-script"
| "file-pdf"
| "file-pkg"
| "file-p7m"
| "file-pem"
>;
/**
* A generic ListItem component that can be used to display a list of items. It
* encapsulates the UI logic and styling for displaying a graphic, title,
* details, and actions.
*/
interface IListItemProps {
/** The grahpic you want to display for this list item. */
graphic: ISupportedGraphicNames;
title: string;
details: React.ReactNode;
/** A collection of React Nodes that will render as list item actions. Can be
* used to render buttons, links, etc.
*/
actions: React.ReactNode;
className?: string;
}
const ListItem = ({
graphic,
title,
details,
actions,
className,
}: IListItemProps) => {
const classNames = classnames(baseClass, className);
return (
<div className={classNames}>
<div className={`${baseClass}__main-content`}>
<Graphic name={graphic} />
<div className={`${baseClass}__info`}>
<span className={`${baseClass}__title`}>{title}</span>
<div className={`${baseClass}__details`}>{details}</div>
</div>
</div>
<div className={`${baseClass}__actions`}>{actions}</div>
</div>
);
};
export default ListItem;
+31
View File
@@ -0,0 +1,31 @@
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
&__main-content {
display: flex;
align-items: center;
gap: $pad-medium;
}
&__info {
display: flex;
flex-direction: column;
}
&__title {
font-size: $x-small;
font-weight: $bold;
}
&__details {
font-size: $xx-small;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: $pad-medium;
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from "./ListItem";
+2
View File
@@ -55,6 +55,8 @@ export interface IMdmSummaryResponse {
mobile_device_management_solution: IMdmSolution[] | null;
}
type SupportedMdmPlatform = "darwin" | "windows";
export interface IMdmProfile {
profile_id: number;
team_id: number;
@@ -1,27 +1,27 @@
import React, { useContext, useRef, useState } from "react";
import { useQuery } from "react-query";
import { AxiosResponse } from "axios";
import { IApiError } from "interfaces/errors";
import { IMdmProfile, IMdmProfilesResponse } from "interfaces/mdm";
import mdmAPI from "services/entities/mdm";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import CustomLink from "components/CustomLink";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import FileUploader from "../../../components/FileUploader";
import UploadList from "../../../components/UploadList";
import { UPLOAD_ERROR_MESSAGES, getErrorMessage } from "./helpers";
import DeleteProfileModal from "./components/DeleteProfileModal/DeleteProfileModal";
import ProfileListItem from "./components/ProfileListItem";
import ProfileListHeading from "./components/ProfileListHeading";
import ProfileUploader from "./components/ProfileUploader";
const baseClass = "custom-settings";
interface ICustomSettingsProps {
currentTeamId: number;
/** handler that fires when a change occures on the section (e.g. disk encryption
* enabled, profile uploaded) */
onMutation: () => void;
}
@@ -32,7 +32,6 @@ const CustomSettings = ({
const { renderFlash } = useContext(NotificationContext);
const [showDeleteProfileModal, setShowDeleteProfileModal] = useState(false);
const [showLoading, setShowLoading] = useState(false);
const selectedProfile = useRef<IMdmProfile | null>(null);
@@ -41,48 +40,23 @@ const CustomSettings = ({
setShowDeleteProfileModal(true);
};
const { data: profiles, refetch: refetchProfiles } = useQuery<
IMdmProfilesResponse,
unknown,
IMdmProfile[] | null
>(["profiles", currentTeamId], () => mdmAPI.getProfiles(currentTeamId), {
select: (data) => data.profiles,
refetchOnWindowFocus: false,
});
const onFileUpload = async (files: FileList | null) => {
setShowLoading(true);
if (!files || files.length === 0) {
setShowLoading(false);
return;
const {
data: profiles,
isLoading: isLoadingProfiles,
isError: isErrorProfiles,
refetch: refetchProfiles,
} = useQuery<IMdmProfilesResponse, unknown, IMdmProfile[] | null>(
["profiles", currentTeamId],
() => mdmAPI.getProfiles(currentTeamId),
{
select: (data) => data.profiles,
refetchOnWindowFocus: false,
}
);
const file = files[0];
if (
// file.type might be empty on some systems as uncommon file extensions
// would return an empty string.
(file.type !== "" && file.type !== "application/x-apple-aspen-config") ||
!file.name.includes(".mobileconfig")
) {
renderFlash("error", UPLOAD_ERROR_MESSAGES.wrongType.message);
setShowLoading(false);
return;
}
try {
await mdmAPI.uploadProfile(file, currentTeamId);
refetchProfiles();
onMutation();
renderFlash("success", "Successfully uploaded!");
} catch (e) {
const error = e as AxiosResponse<IApiError>;
const errMessage = getErrorMessage(error);
renderFlash("error", errMessage);
} finally {
setShowLoading(false);
}
const onUploadProfile = () => {
refetchProfiles();
onMutation();
};
const onCancelDelete = () => {
@@ -104,6 +78,30 @@ const CustomSettings = ({
}
};
const renderProfileList = () => {
if (isLoadingProfiles) {
return <Spinner />;
}
if (isErrorProfiles) {
return <DataError />;
}
if (!profiles || profiles.length === 0) {
return null;
}
return (
<UploadList
listItems={profiles}
HeadingComponent={ProfileListHeading}
ListItemComponent={({ listItem }) => (
<ProfileListItem profile={listItem} onDelete={onClickDelete} />
)}
/>
);
};
return (
<div className={baseClass}>
<h2>Custom settings</h2>
@@ -115,24 +113,10 @@ const CustomSettings = ({
url="https://fleetdm.com/docs/using-fleet/mdm-custom-macos-settings"
/>
</p>
{profiles && (
<UploadList
listItems={profiles}
HeadingComponent={ProfileListHeading}
ListItemComponent={({ listItem }) => (
<ProfileListItem profile={listItem} onDelete={onClickDelete} />
)}
/>
)}
<FileUploader
graphicName="file-configuration-profile"
message="Configuration profile (.mobileconfig)"
accept=".mobileconfig,application/x-apple-aspen-config"
isLoading={showLoading}
onFileUpload={onFileUpload}
className={`${baseClass}__file-uploader`}
{renderProfileList()}
<ProfileUploader
currentTeamId={currentTeamId}
onUpload={onUploadProfile}
/>
{showDeleteProfileModal && selectedProfile.current && (
<DeleteProfileModal
@@ -7,9 +7,24 @@ import mdmAPI from "services/entities/mdm";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import ListItem from "components/ListItem";
const baseClass = "profile-list-item";
interface IProfileDetailsProps {
createdAt: string;
}
const ProfileDetails = ({ createdAt }: IProfileDetailsProps) => {
return (
<div className={`${baseClass}__profile-details`}>
<span className={`${baseClass}__list-item-uploaded`}>
{`Uploaded ${formatDistanceToNow(new Date(createdAt))} ago`}
</span>
</div>
);
};
interface IProfileListItemProps {
profile: IMdmProfile;
onDelete: (profile: IMdmProfile) => void;
@@ -25,35 +40,30 @@ const ProfileListItem = ({ profile, onDelete }: IProfileListItemProps) => {
};
return (
<div className={baseClass}>
<div className={`${baseClass}__list-item-data`}>
<Icon name="profile" />
<div className={`${baseClass}__list-item-info`}>
<span className={`${baseClass}__list-item-name`}>{profile.name}</span>
<span className={`${baseClass}__list-item-uploaded`}>
{`Uploaded ${formatDistanceToNow(
new Date(profile.created_at)
)} ago`}
</span>
</div>
</div>
<div className={`${baseClass}__list-item-actions`}>
<Button
className={`${baseClass}__list-item-button`}
variant="text-icon"
onClick={onClickDownload}
>
<Icon name="download" />
</Button>
<Button
className={`${baseClass}__list-item-button`}
variant="text-icon"
onClick={() => onDelete(profile)}
>
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
</div>
</div>
<ListItem
className={baseClass}
graphic="file-configuration-profile"
title={profile.name}
details={<ProfileDetails createdAt={profile.created_at} />}
actions={
<>
<Button
className={`${baseClass}__action-button`}
variant="text-icon"
onClick={onClickDownload}
>
<Icon name="download" />
</Button>
<Button
className={`${baseClass}__action-button`}
variant="text-icon"
onClick={() => onDelete(profile)}
>
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
</>
}
/>
);
};
@@ -1,3 +1,19 @@
.profile-list-item {
@include list-item;
&__profile-details {
display: flex;
flex-direction: row;
gap: $pad-xsmall;
}
&__list-item-details {
display: flex;
flex-direction: row;
gap: $pad-xsmall;
font-size: $xx-small;
}
&__action-button {
width: 40px;
height: 40px;
}
}
@@ -0,0 +1,73 @@
import React, { useContext, useState } from "react";
import { AxiosResponse } from "axios";
import { IApiError } from "interfaces/errors";
import { NotificationContext } from "context/notification";
import mdmAPI from "services/entities/mdm";
import FileUploader from "components/FileUploader";
import { UPLOAD_ERROR_MESSAGES, getErrorMessage } from "./helpers";
const baseClass = "profile-uploader";
interface IProfileUploaderProps {
currentTeamId: number;
onUpload: () => void;
}
const ProfileUploader = ({
currentTeamId,
onUpload,
}: IProfileUploaderProps) => {
const [showLoading, setShowLoading] = useState(false);
const { renderFlash } = useContext(NotificationContext);
const onFileUpload = async (files: FileList | null) => {
setShowLoading(true);
if (!files || files.length === 0) {
setShowLoading(false);
return;
}
const file = files[0];
if (
// file.type might be empty on some systems as uncommon file extensions
// would return an empty string.
(file.type !== "" && file.type !== "application/x-apple-aspen-config") ||
!file.name.includes(".mobileconfig")
) {
renderFlash("error", UPLOAD_ERROR_MESSAGES.wrongType.message);
setShowLoading(false);
return;
}
try {
await mdmAPI.uploadProfile(file, currentTeamId);
renderFlash("success", "Successfully uploaded!");
onUpload();
} catch (e) {
const error = e as AxiosResponse<IApiError>;
const errMessage = getErrorMessage(error);
renderFlash("error", errMessage);
} finally {
setShowLoading(false);
}
};
return (
<FileUploader
graphicName="file-configuration-profile"
message="Configuration profile (.mobileconfig)"
accept=".mobileconfig,application/x-apple-aspen-config"
isLoading={showLoading}
onFileUpload={onFileUpload}
className={`${baseClass}__file-uploader`}
/>
);
};
export default ProfileUploader;
@@ -0,0 +1 @@
export { default } from "./ProfileUploader";
@@ -1,8 +1,4 @@
import React from "react";
import ReactTooltip from "react-tooltip";
import Icon from "components/Icon";
import { COLORS } from "styles/var/colors";
const baseClass = "script-list-heading";
@@ -17,38 +13,6 @@ const ScriptListHeading = () => {
>
<span>Actions</span>
</div>
<ReactTooltip
type="dark"
effect="solid"
id="ran"
backgroundColor={COLORS["tooltip-bg"]}
>
<span className={`${baseClass}__tooltip-text`}>
Script ran and exited with status code 0.
</span>
</ReactTooltip>
<ReactTooltip
type="dark"
effect="solid"
id="pending"
backgroundColor={COLORS["tooltip-bg"]}
>
<span className={`${baseClass}__tooltip-text`}>
Script will run when the host comes online.
</span>
</ReactTooltip>
<ReactTooltip
type="dark"
effect="solid"
id="errors"
backgroundColor={COLORS["tooltip-bg"]}
>
<span className={`${baseClass}__tooltip-text`}>
Script ran and exited with a non-zero status code. Click on a host to
view error(s).
</span>
</ReactTooltip>
</div>
);
};
@@ -1,4 +1,5 @@
.script-list-heading {
font-weight: $bold;
display: flex;
justify-content: space-between;
@@ -7,7 +7,7 @@ import scriptAPI, { IScript } from "services/entities/scripts";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import Graphic from "components/Graphic";
import ListItem from "components/ListItem";
const baseClass = "script-list-item";
@@ -45,33 +45,34 @@ const ScriptListItem = ({ script, onDelete }: IScriptListItemProps) => {
};
return (
<div className={baseClass}>
<div className={`${baseClass}__value-group ${baseClass}__script-data`}>
<Graphic name={getFileIconName(script.name)} />
<div className={`${baseClass}__script-info`}>
<span className={`${baseClass}__script-name`}>{script.name}</span>
<span className={`${baseClass}__script-uploaded`}>
{`Uploaded ${formatDistanceToNow(new Date(script.created_at))} ago`}
</span>
</div>
</div>
<div className={`${baseClass}__value-group ${baseClass}__script-actions`}>
<Button
className={`${baseClass}__download-button`}
variant="text-icon"
onClick={onClickDownload}
>
<Icon name="download" />
</Button>
<Button
className={`${baseClass}__delete-button`}
variant="text-icon"
onClick={() => onDelete(script)}
>
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
</div>
</div>
<ListItem
className={baseClass}
graphic={getFileIconName(script.name)}
title={script.name}
details={
<span>{`Uploaded ${formatDistanceToNow(
new Date(script.created_at)
)} ago`}</span>
}
actions={
<>
<Button
className={`${baseClass}__action-button`}
variant="text-icon"
onClick={onClickDownload}
>
<Icon name="download" />
</Button>
<Button
className={`${baseClass}__action-button`}
variant="text-icon"
onClick={() => onDelete(script)}
>
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
</>
}
/>
);
};
@@ -1,68 +1,6 @@
.script-list-item {
display: flex;
align-items: center;
&__value-group {
flex: 1;
}
&__script-data {
display: flex;
align-items: center;
}
&__script-info {
margin-left: $pad-medium;
display: flex;
flex-direction: column;
}
&__script-name {
font-size: $x-small;
}
&__script-uploaded {
font-size: $xx-small;
}
&__script-statuses {
display: flex;
justify-content: center;
span {
width: 100px;
text-align: center;
}
}
&__has-value {
color: $core-vibrant-blue;
}
&__script-actions {
display: flex;
justify-content: flex-end;
}
&__refresh-button,
&__download-button,
&__delete-button {
&__action-button {
width: 40px;
height: 40px;
}
&__refresh-button,
&__download-button {
margin-right: $pad-medium;
}
@media (max-width: $break-md) {
&__script-statuses {
justify-content: flex-end;
}
&__script-actions {
display: none;
}
}
}
@@ -5,7 +5,8 @@ import { IApiError } from "interfaces/errors";
import { NotificationContext } from "context/notification";
import scriptAPI from "services/entities/scripts";
import FileUploader from "pages/ManageControlsPage/components/FileUploader";
import FileUploader from "components/FileUploader";
import { getErrorMessage } from "./helpers";
const baseClass = "script-uploader";
@@ -6,7 +6,8 @@ import { NotificationContext } from "context/notification";
import mdmAPI from "services/entities/mdm";
import CustomLink from "components/CustomLink";
import FileUploader from "pages/ManageControlsPage/components/FileUploader";
import FileUploader from "components/FileUploader";
import { UPLOAD_ERROR_MESSAGES, getErrorMessage } from "./helpers";
const baseClass = "bootstrap-package-uploader";
@@ -5,7 +5,7 @@ import { IApiError } from "interfaces/errors";
import mdmAPI from "services/entities/mdm";
import { NotificationContext } from "context/notification";
import FileUploader from "pages/ManageControlsPage/components/FileUploader/FileUploader";
import FileUploader from "components/FileUploader/FileUploader";
import CustomLink from "components/CustomLink";
import { UPLOAD_ERROR_MESSAGES, getErrorMessage } from "./helpers";
+1
View File
@@ -68,6 +68,7 @@ $max-width: 2560px;
&__list-item-name {
font-size: $x-small;
font-weight: $bold;
}
&__list-item-uploaded {