diff --git a/frontend/components/FileUploader/FileUploader.stories.tsx b/frontend/components/FileUploader/FileUploader.stories.tsx new file mode 100644 index 0000000000..68de42c15d --- /dev/null +++ b/frontend/components/FileUploader/FileUploader.stories.tsx @@ -0,0 +1,24 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import FileUploader from "./FileUploader"; + +const meta: Meta = { + 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; + +export const Basic: Story = {}; diff --git a/frontend/pages/ManageControlsPage/components/FileUploader/FileUploader.tsx b/frontend/components/FileUploader/FileUploader.tsx similarity index 75% rename from frontend/pages/ManageControlsPage/components/FileUploader/FileUploader.tsx rename to frontend/components/FileUploader/FileUploader.tsx index 31c32ecf2b..556439e13c 100644 --- a/frontend/pages/ManageControlsPage/components/FileUploader/FileUploader.tsx +++ b/frontend/components/FileUploader/FileUploader.tsx @@ -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 = ({

{message}

-

{additionalInfo}

+ {additionalInfo && ( +

{additionalInfo}

+ )} + + + ), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = {}; diff --git a/frontend/components/ListItem/ListItem.tsx b/frontend/components/ListItem/ListItem.tsx new file mode 100644 index 0000000000..6c6323bd4d --- /dev/null +++ b/frontend/components/ListItem/ListItem.tsx @@ -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 ( +
+
+ +
+ {title} +
{details}
+
+
+
{actions}
+
+ ); +}; + +export default ListItem; diff --git a/frontend/components/ListItem/_styles.scss b/frontend/components/ListItem/_styles.scss new file mode 100644 index 0000000000..d184de2728 --- /dev/null +++ b/frontend/components/ListItem/_styles.scss @@ -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; + } +} diff --git a/frontend/components/ListItem/index.ts b/frontend/components/ListItem/index.ts new file mode 100644 index 0000000000..3fb6c1bab3 --- /dev/null +++ b/frontend/components/ListItem/index.ts @@ -0,0 +1 @@ +export { default } from "./ListItem"; diff --git a/frontend/interfaces/mdm.ts b/frontend/interfaces/mdm.ts index 9cb068b0df..cab185ece7 100644 --- a/frontend/interfaces/mdm.ts +++ b/frontend/interfaces/mdm.ts @@ -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; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx index db92bd845e..f1c45adf9d 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx @@ -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(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( + ["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; - 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 ; + } + + if (isErrorProfiles) { + return ; + } + + if (!profiles || profiles.length === 0) { + return null; + } + + return ( + ( + + )} + /> + ); + }; + return (

Custom settings

@@ -115,24 +113,10 @@ const CustomSettings = ({ url="https://fleetdm.com/docs/using-fleet/mdm-custom-macos-settings" />

- - {profiles && ( - ( - - )} - /> - )} - - {showDeleteProfileModal && selectedProfile.current && ( { + return ( +
+ + {`Uploaded ${formatDistanceToNow(new Date(createdAt))} ago`} + +
+ ); +}; + interface IProfileListItemProps { profile: IMdmProfile; onDelete: (profile: IMdmProfile) => void; @@ -25,35 +40,30 @@ const ProfileListItem = ({ profile, onDelete }: IProfileListItemProps) => { }; return ( -
-
- -
- {profile.name} - - {`Uploaded ${formatDistanceToNow( - new Date(profile.created_at) - )} ago`} - -
-
-
- - -
-
+ } + actions={ + <> + + + + } + /> ); }; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileListItem/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileListItem/_styles.scss index d22eea6a6e..a9911c3428 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileListItem/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileListItem/_styles.scss @@ -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; + } } diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/ProfileUploader.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/ProfileUploader.tsx new file mode 100644 index 0000000000..dd1168f0ab --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/ProfileUploader.tsx @@ -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; + const errMessage = getErrorMessage(error); + renderFlash("error", errMessage); + } finally { + setShowLoading(false); + } + }; + + return ( + + ); +}; + +export default ProfileUploader; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/helpers.ts b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/helpers.ts similarity index 100% rename from frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/helpers.ts rename to frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/helpers.ts diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/index.ts b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/index.ts new file mode 100644 index 0000000000..9cc11b7ea3 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/index.ts @@ -0,0 +1 @@ +export { default } from "./ProfileUploader"; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListHeading/ScriptListHeading.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListHeading/ScriptListHeading.tsx index 4b1503521e..e4e08c12b8 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListHeading/ScriptListHeading.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListHeading/ScriptListHeading.tsx @@ -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 = () => { > Actions
- - - - Script ran and exited with status code 0. - - - - - Script will run when the host comes online. - - - - - Script ran and exited with a non-zero status code. Click on a host to - view error(s). - - ); }; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListHeading/_styles.scss b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListHeading/_styles.scss index 5670731f47..3094cf5e33 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListHeading/_styles.scss +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListHeading/_styles.scss @@ -1,4 +1,5 @@ .script-list-heading { + font-weight: $bold; display: flex; justify-content: space-between; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx index b8ecead6a4..d0b3f848eb 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx @@ -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 ( -
-
- -
- {script.name} - - {`Uploaded ${formatDistanceToNow(new Date(script.created_at))} ago`} - -
-
-
- - -
-
+ {`Uploaded ${formatDistanceToNow( + new Date(script.created_at) + )} ago`} + } + actions={ + <> + + + + } + /> ); }; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/_styles.scss b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/_styles.scss index 59402e64bc..38727f6444 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/_styles.scss +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/_styles.scss @@ -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; - } - } } diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx index a53d4135a5..bee6f85f0a 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx @@ -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"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageUploader/BootstrapPackageUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageUploader/BootstrapPackageUploader.tsx index c332d0b12f..71de0ef3ec 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageUploader/BootstrapPackageUploader.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageUploader/BootstrapPackageUploader.tsx @@ -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"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/components/EulaSection/components/EulaUploader/EulaUploader.tsx b/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/components/EulaSection/components/EulaUploader/EulaUploader.tsx index 28b22e9af7..89933f4f11 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/components/EulaSection/components/EulaUploader/EulaUploader.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/components/EulaSection/components/EulaUploader/EulaUploader.tsx @@ -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"; diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index ab3e740616..7ae94ddcd2 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -68,6 +68,7 @@ $max-width: 2560px; &__list-item-name { font-size: $x-small; + font-weight: $bold; } &__list-item-uploaded {