Fleet UI: Update previews to look the same (#38241)

This commit is contained in:
RachelElysia
2026-01-13 15:53:21 -05:00
committed by GitHub
parent b6095ab5a6
commit e9b2daa761
13 changed files with 395 additions and 246 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

@@ -158,6 +158,8 @@ interface ISoftwareNameCellProps {
iconUrl?: string | null;
isIosOrIpadosApp?: boolean;
isAndroidPlayStoreApp?: boolean;
/** Only used on Edit icon modal to render a preview of the chosen unsaved icon */
previewIcon?: JSX.Element;
}
const SoftwareNameCell = ({
@@ -173,8 +175,11 @@ const SoftwareNameCell = ({
iconUrl,
isIosOrIpadosApp = false,
isAndroidPlayStoreApp = false,
previewIcon,
}: ISoftwareNameCellProps) => {
const icon = <SoftwareIcon name={name} source={source} url={iconUrl} />;
const icon = previewIcon || (
<SoftwareIcon name={name} source={source} url={iconUrl} />
);
// My device page > Software fake link as entire row opens a modal
if (pageContext === "deviceUser" && !isSelfService) {
return (
@@ -1,6 +1,8 @@
import React, { useContext, useEffect, useState, useCallback } from "react";
import { useQuery } from "react-query";
import { Tab, Tabs, TabList, TabPanel } from "react-tabs";
import { noop } from "lodash";
import {
IAppStoreApp,
isIpadOrIphoneSoftwareSource,
@@ -10,6 +12,7 @@ import {
import { IInputFieldParseTarget } from "interfaces/form_field";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import { INotification } from "interfaces/notification";
import { getErrorReason } from "interfaces/errors";
import softwareAPI from "services/entities/software";
@@ -25,17 +28,14 @@ import Card from "components/Card";
import Button from "components/buttons/Button";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import TableCount from "components/TableContainer/TableCount";
import CardHeader from "components/CardHeader";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import Spinner from "components/Spinner";
import { isSafeImagePreviewUrl } from "pages/SoftwarePage/helpers";
import SoftwareDetailsSummary from "pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary";
import { SELF_SERVICE_SUBHEADER } from "pages/hosts/details/cards/Software/SelfService/SelfService";
import { BasicSoftwareTable } from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal/CategoriesEndUserExperienceModal";
import SelfServicePreview from "pages/SoftwarePage/components/cards/SelfServicePreview";
import { TitleVersionsLastUpdatedInfo } from "../SoftwareSummaryCard/TitleVersionsTable/TitleVersionsTable";
import PreviewSelfServiceIcon from "../../../../../assets/images/preview-self-service-icon.png";
import PreviewSelfServiceMobileIcon from "../../../../../assets/images/preview-self-service-mobile-icon.png";
const baseClass = "edit-icon-modal";
@@ -152,6 +152,7 @@ const EditIconModal = ({
previewInfo,
}: IEditIconModalProps) => {
const { renderFlash, renderMultiFlash } = useContext(NotificationContext);
const { config } = useContext(AppContext);
const isSoftwarePackage = installerType === "package";
const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(
@@ -474,111 +475,82 @@ const EditIconModal = ({
);
};
const renderPreviewSelfServiceCard = () => (
<Card
borderRadiusSize="medium"
color="grey"
className={`${baseClass}__preview-card`}
paddingSize="xlarge"
>
<Card
className={`${baseClass}__preview-card__self-service`}
borderRadiusSize="xxlarge"
>
<CardHeader header="Self-service" subheader={SELF_SERVICE_SUBHEADER} />
<div className={`${baseClass}__preview-img-container`}>
<img
className={`${baseClass}__preview-img`}
src={PreviewSelfServiceIcon}
alt="Preview icon on Fleet Desktop > Self-service"
/>
</div>
<div className={`${baseClass}__self-service-preview`}>
{iconState.previewUrl &&
isSafeImagePreviewUrl(iconState.previewUrl) ? (
<img
src={iconState.previewUrl}
alt="Uploaded self-service icon"
style={{
width: 20,
height: 20,
borderRadius: "4px",
overflow: "hidden",
}}
/>
) : (
// Known limitation: we cannot see VPP app icons as the fallback when a custom icon
// is set as VPP icon is not returned by the API if a custom icon is returned
<SoftwareIcon
name={previewInfo.titleName}
source={previewInfo.source}
url={isSoftwarePackage ? undefined : software.icon_url} // fallback PNG icons only exist for VPP apps
uploadedAt={iconUploadedAt}
/>
)}
<div className={`${baseClass}__self-service-preview-name`}>
<TooltipTruncatedText
value={displayName || previewInfo.titleName}
/>
</div>
</div>
</Card>
<div
className={`${baseClass}__mask-overlay ${baseClass}__mask-overlay--self-service`}
/>
</Card>
);
/**
* Preview matches preview in Edit Software modal > Categories End User Experience modal
* Non-mobile preview:
* - uses HTML/CSS instead for maintainability as the self-service UI changes
* - dynamic name/icon
*
* Mobile preview modal:
* - uses a screenshot
* - dynamic name/icon/version
*/
const renderPreviewSelfServiceMobileCard = () => (
<Card
borderRadiusSize="medium"
color="white"
className={`${baseClass}__preview-card`}
paddingSize="xlarge"
>
<div className={`${baseClass}__preview-img-container--mobile`}>
<img
className={`${baseClass}__preview-img--mobile`}
src={PreviewSelfServiceMobileIcon}
alt="Preview icon on Fleet Desktop > Self-service"
/>
</div>
<div className={`${baseClass}__self-service-preview--mobile`}>
{iconState.previewUrl && isSafeImagePreviewUrl(iconState.previewUrl) ? (
const renderPreviewSelfServiceCard = () => (
<SelfServicePreview
isIosOrIpadosApp={isIosOrIpadosApp}
contactUrl={config?.org_info.contact_url || ""}
name={previewInfo.name}
displayName={displayName || previewInfo.titleName}
versionLabel={
"latest_version" in software
? software.latest_version
: software.version ||
previewInfo.selfServiceVersion ||
"Version (unknown)"
}
renderIcon={() =>
iconState.previewUrl && isSafeImagePreviewUrl(iconState.previewUrl) ? (
<img
src={iconState.previewUrl}
alt="Uploaded self-service icon"
style={{
width: 20,
height: 20,
width: 24,
height: 24,
borderRadius: "4px",
overflow: "hidden",
}}
/>
) : (
// Known limitation: we cannot see VPP app icons as the fallback when a custom icon
// is set as VPP icon is not returned by the API if a custom icon is returned
<SoftwareIcon
name={previewInfo.name}
source={previewInfo.source}
url={isSoftwarePackage ? undefined : software.icon_url} // fallback PNG icons only exist for VPP apps
uploadedAt={iconUploadedAt}
/>
)}
<div
className={`${baseClass}__self-service-preview-name-version--mobile`}
>
<div className={`${baseClass}__self-service-preview-name--mobile`}>
<TooltipTruncatedText value={displayName || previewInfo.name} />
</div>
<div className={`${baseClass}__self-service-preview-version--mobile`}>
{"latest_version" in software
? software.latest_version
: software.version || "Version (unknown)"}
</div>
</div>
</div>
</Card>
)
}
renderTable={() => (
<BasicSoftwareTable
name={displayName || previewInfo.titleName}
displayName={displayName || previewInfo.titleName}
source={previewInfo.source}
iconUrl={isSoftwarePackage ? undefined : software.icon_url}
previewIcon={
iconState.previewUrl &&
isSafeImagePreviewUrl(iconState.previewUrl) ? (
<img
src={iconState.previewUrl}
alt="Uploaded self-service icon"
style={{
width: 24,
height: 24,
borderRadius: "4px",
overflow: "hidden",
}}
/>
) : (
<SoftwareIcon
name={previewInfo.titleName}
source={previewInfo.source}
url={isSoftwarePackage ? undefined : software.icon_url}
uploadedAt={iconUploadedAt}
/>
)
}
/>
)}
/>
);
const renderForm = () => (
@@ -623,11 +595,7 @@ const EditIconModal = ({
</Tab>
</TabList>
<TabPanel>{renderPreviewFleetCard()}</TabPanel>
<TabPanel>
{isIosOrIpadosApp
? renderPreviewSelfServiceMobileCard()
: renderPreviewSelfServiceCard()}
</TabPanel>
<TabPanel>{renderPreviewSelfServiceCard()}</TabPanel>
</Tabs>
</TabNav>
</>
@@ -7,8 +7,12 @@
@include vertical-modal-layout;
}
.self-service-preview__preview-card {
height: 230px; // Match height of Fleet UI preview
}
&__preview-card {
max-height: 203px;
height: 230px; // Match height of self-service preview
overflow: hidden;
&__fleet,
@@ -28,22 +32,6 @@
border-radius: $border-radius-xxlarge;
}
}
&__self-service {
height: 350px;
}
}
&__preview-img-container {
img {
width: 124%;
}
}
&__preview-img-container--mobile {
img {
width: 100%; // TODO
}
}
&__preview-results-count {
@@ -78,22 +66,14 @@
}
}
// Specific offsets for mobile in edit modal
&__self-service-preview--mobile {
position: relative;
top: -88px;
left: 156px;
display: flex;
gap: $pad-small;
font-size: 12px;
align-items: center;
max-width: 210px;
.software-icon__xsmall {
border-radius: $border-radius-small;
}
}
// Required for tooltip truncating long software names
// Required for tooltip truncating long software names (desktop)
&__self-service-preview-name {
max-width: 180px;
white-space: nowrap;
@@ -101,22 +81,6 @@
text-overflow: ellipsis;
}
&__self-service-preview-name-version--mobile {
display: flex;
flex-direction: column;
font-size: $xxx-small;
}
// Required for tooltip truncating long software names
&__self-service-preview-name--mobile {
max-width: 115px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
font-weight: $bold;
}
&__mask-overlay {
position: relative;
width: 550px;
@@ -129,9 +93,10 @@
);
&--fleet {
bottom: 156px;
bottom: 130px;
height: 135px;
}
&--self-service {
bottom: 200px;
height: 75px;
@@ -58,6 +58,7 @@ interface IEditSoftwareModalProps {
name: string;
displayName: string;
source?: string;
iconUrl?: string | null;
}
const EditSoftwareModal = ({
@@ -73,6 +74,7 @@ const EditSoftwareModal = ({
name,
displayName,
source,
iconUrl = undefined,
}: IEditSoftwareModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const { config } = useContext(AppContext);
@@ -404,9 +406,14 @@ const EditSoftwareModal = ({
name={name}
displayName={displayName}
source={source}
iconUrl={softwareInstaller.icon_url || undefined}
iconUrl={iconUrl} // Must be software title icon url not installer icon url
onCancel={togglePreviewEndUserExperienceModal}
isIosOrIpadosApp={isIosOrIpadosApp}
mobileVersion={
("latest_version" in softwareInstaller &&
softwareInstaller.latest_version) ||
softwareInstaller.version
}
/>
)}
{!!pendingPackageUpdates.software && showFileProgressModal && (
@@ -202,6 +202,7 @@ const SoftwareSummaryCard = ({
name={softwareTitle.name}
displayName={softwareTitle.display_name || softwareTitle.name}
source={softwareTitle.source}
iconUrl={softwareTitle.icon_url}
/>
)}
{showEditConfigurationModal && hasValidTeamId && softwareInstaller && (
@@ -0,0 +1,65 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import SelfServicePreview from "./SelfServicePreview";
describe("SelfServicePreview", () => {
it("renders mobile preview with screenshot, name, version, and icon", () => {
const MockIcon = () => <div>Mock icon</div>;
render(
<SelfServicePreview
isIosOrIpadosApp
contactUrl="https://example.com/help"
name="App name"
displayName="Display name"
versionLabel="1.2.3"
renderIcon={() => <MockIcon />}
/>
);
expect(
screen.getByAltText("Preview icon on Fleet Desktop > Self-service")
).toBeVisible();
expect(screen.getByText("Mock icon")).toBeVisible();
expect(screen.getByText("Display name")).toBeVisible();
expect(screen.getByText("1.2.3")).toBeVisible();
});
it("falls back to name when displayName is empty in mobile preview", () => {
render(
<SelfServicePreview
isIosOrIpadosApp
contactUrl="https://example.com/help"
name="Fallback name"
displayName=""
versionLabel="1.2.3"
renderIcon={() => <div>Icon</div>}
/>
);
expect(screen.getByText("Fallback name")).toBeVisible();
});
it("renders desktop preview with header, search field, categories menu, and table", () => {
const MockTable = () => <div>Mock table</div>;
render(
<SelfServicePreview
isIosOrIpadosApp={false}
contactUrl="https://example.com/help"
name="App name"
displayName="Display name"
versionLabel="1.2.3"
renderIcon={() => <div>Icon</div>}
renderTable={() => <MockTable />}
/>
);
expect(screen.getByText(/Self-service/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search by name")).toBeInTheDocument();
expect(screen.getByText(/Browsers/i)).toBeInTheDocument();
expect(screen.getByText("Mock table")).toBeVisible();
});
});
@@ -0,0 +1,117 @@
/**
* Used on Software title > CategoriesEndUserExperienceModal and Software title > Edit Appearance Modal
*
* Non-mobile preview modal:
* - uses HTML/CSS instead for maintainability as the self-service UI changes
* - dynamic name/icon
*
* Mobile preview modal:
* - uses a screenshot
* - dynamic name/icon/version
*/
import React from "react";
import { noop } from "lodash";
import Card from "components/Card";
import SearchField from "components/forms/fields/SearchField";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import CategoriesMenu from "pages/hosts/details/cards/Software/SelfService/components/CategoriesMenu";
import SelfServiceHeader from "pages/hosts/details/cards/Software/SelfService/components/SelfServiceHeader";
import { CATEGORIES_NAV_ITEMS } from "pages/hosts/details/cards/Software/SelfService/helpers";
import PreviewSelfServiceMobileIcon from "../../../../../../assets/images/preview-self-service-mobile-icon.png";
const baseClass = "self-service-preview";
interface ISelfServicePreviewProps {
/** iOS/iPadOS uses screenshot + dynamic overlay; otherwise HTML preview */
isIosOrIpadosApp: boolean;
/** Shared data for mobile preview */
contactUrl: string;
name: string;
displayName: string;
versionLabel: string;
/** What to render for the app icon in the list (img or <SoftwareIcon/>) */
renderIcon: () => React.ReactNode;
/** What to render as the “table” area for desktop (e.g. BasicSoftwareTable) */
renderTable?: () => React.ReactNode;
}
const SelfServicePreview = ({
isIosOrIpadosApp,
contactUrl,
name,
displayName,
versionLabel,
renderIcon,
renderTable,
}: ISelfServicePreviewProps) => {
if (isIosOrIpadosApp) {
// Mobile preview with screenshot + overlay
return (
<Card
borderRadiusSize="medium"
color="white"
className={`${baseClass}__preview-card ${baseClass}__preview-card--mobile`}
paddingSize="xlarge"
>
<div className={`${baseClass}__preview-img-container--mobile`}>
<img
className={`${baseClass}__preview-img--mobile`}
src={PreviewSelfServiceMobileIcon}
alt="Preview icon on Fleet Desktop > Self-service"
/>
</div>
<div className={`${baseClass}__self-service-preview--mobile`}>
{renderIcon()}
<div
className={`${baseClass}__self-service-preview-name-version--mobile`}
>
<div className={`${baseClass}__self-service-preview-name--mobile`}>
<TooltipTruncatedText value={displayName || name} />
</div>
<div
className={`${baseClass}__self-service-preview-version--mobile`}
>
{versionLabel}
</div>
</div>
</div>
</Card>
);
}
// Desktop HTML/CSS self-service preview
return (
<Card
borderRadiusSize="medium"
color="grey"
className={`${baseClass}__preview-card`}
paddingSize="xlarge"
>
<div className={`${baseClass}__disabled-overlay`} />
<Card
className={`${baseClass}__preview-card__self-service`}
borderRadiusSize="xxlarge"
>
<SelfServiceHeader contactUrl={contactUrl} variant="preview" />
<SearchField placeholder="Search by name" onChange={noop} disabled />
<div className={`${baseClass}__table`}>
<CategoriesMenu
categories={CATEGORIES_NAV_ITEMS}
queryParams={{
query: "",
order_direction: "asc",
order_key: "name",
page: 0,
per_page: 100,
}}
readOnly
/>
{renderTable && renderTable()}
</div>
</Card>
</Card>
);
};
export default SelfServicePreview;
@@ -0,0 +1,77 @@
.self-service-preview {
&__preview-card {
overflow: hidden;
height: 310px;
&--mobile {
height: 230px; // Mobile preview is shorter height than Desktop preview
}
&__self-service {
width: 515px;
height: 310px;
@include vertical-card-layout;
}
.card-header__header {
font-size: initial;
}
.categories-menu {
top: 0;
width: 200px;
}
.data-table__table {
width: 325px;
}
}
&__preview-img-container--mobile {
img {
width: 100%;
}
}
&__table {
display: flex;
flex-direction: row;
}
&__disabled-overlay {
position: absolute;
width: 540px;
height: 335px;
z-index: 1000;
}
&__self-service-preview--mobile {
position: relative;
display: flex;
align-items: center;
max-width: 210px;
left: 154px;
top: -85px;
gap: $pad-xsmall;
.software-icon__xsmall {
border-radius: $border-radius-small;
}
}
&__self-service-preview-name-version--mobile {
display: flex;
flex-direction: column;
font-size: 8px;
max-width: 110px; // Required for tooltip truncating long software names (mobile)
}
// Required for tooltip truncating long software names
&__self-service-preview-name--mobile {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
font-weight: $bold;
}
}
@@ -0,0 +1 @@
export { default } from "./SelfServicePreview";
@@ -1,15 +1,13 @@
/** Mobile preview modal uses a screenshot
* Non-mobile now uses HTML/CSS instead for
* maintainability as the self-selvice UI changes
/**
* Previews match preview in Edit Appearance modal for Edit Appearance modal
*
* Currently only shown from the edit UI, though wired through the Add UI
* Users currently can set categories only when editing a curent installer
* Users currently can set categories only when editing a current installer
*/
import React, { useContext } from "react";
import { Column } from "react-table";
import { noop } from "lodash";
import { AppContext } from "context/app";
import { IHeaderProps } from "interfaces/datatable_config";
@@ -17,14 +15,10 @@ import TableContainer from "components/TableContainer";
import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import Card from "components/Card";
import SelfServiceHeader from "pages/hosts/details/cards/Software/SelfService/components/SelfServiceHeader";
import SearchField from "components/forms/fields/SearchField";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import CategoriesMenu from "pages/hosts/details/cards/Software/SelfService/components/CategoriesMenu";
import { CATEGORIES_NAV_ITEMS } from "pages/hosts/details/cards/Software/SelfService/helpers";
import CategoriesEndUserExperiencePreviewMobile from "../../../../../../assets/images/categories-end-user-experience-preview-mobile@2x.png";
import SelfServicePreview from "../../cards/SelfServicePreview";
import SoftwareIcon from "../../icons/SoftwareIcon";
const baseClass = "categories-end-user-experience-preview-modal";
@@ -58,7 +52,8 @@ const getData = (
name: string,
displayName: string,
iconUrl: string | null,
source?: string
source?: string,
previewIcon?: JSX.Element
): ISoftwareRow[] => {
const currentSoftwareRow: ISoftwareRow = {
name: (
@@ -69,6 +64,7 @@ const getData = (
iconUrl={iconUrl}
pageContext="deviceUser"
isSelfService
previewIcon={previewIcon}
/>
),
};
@@ -97,18 +93,21 @@ interface BasicSoftwareTableProps {
displayName: string;
source?: string;
iconUrl?: string | null;
/** Render a preview icon instead for edit icon preview */
previewIcon?: JSX.Element;
}
const BasicSoftwareTable = ({
export const BasicSoftwareTable = ({
name,
displayName,
source,
iconUrl = null,
previewIcon,
}: BasicSoftwareTableProps) => {
return (
<TableContainer<ISoftwareRow>
columnConfigs={columns}
data={getData(name, displayName, iconUrl, source)}
data={getData(name, displayName, iconUrl, source, previewIcon)}
isLoading={false}
emptyComponent={EmptyState}
showMarkAllPages={false}
@@ -126,8 +125,9 @@ interface ICategoriesEndUserExperienceModal {
isIosOrIpadosApp?: boolean;
name?: string;
displayName?: string;
iconUrl?: string;
iconUrl?: string | null;
source?: string;
mobileVersion?: string;
}
const CategoriesEndUserExperienceModal = ({
@@ -137,63 +137,35 @@ const CategoriesEndUserExperienceModal = ({
displayName = "Software name",
iconUrl,
source,
mobileVersion,
}: ICategoriesEndUserExperienceModal): JSX.Element => {
const { config } = useContext(AppContext);
return (
<Modal title="End user experience" onExit={onCancel} className={baseClass}>
<>
<span>What end users see:</span>
{isIosOrIpadosApp ? (
<div className={`${baseClass}__preview`}>
<img
src={CategoriesEndUserExperiencePreviewMobile}
alt="Categories end user experience preview"
<SelfServicePreview
isIosOrIpadosApp={isIosOrIpadosApp}
contactUrl={config?.org_info.contact_url || ""}
name={name}
displayName={displayName || name}
versionLabel={mobileVersion || "Version (unknown)"}
renderIcon={() => (
<SoftwareIcon
name={name}
source={source}
url={iconUrl ?? undefined}
/>
</div>
) : (
<Card
borderRadiusSize="medium"
color="grey"
className={`${baseClass}__preview-card`}
paddingSize="xlarge"
>
<div className={`${baseClass}__disabled-overlay`} />
<Card
className={`${baseClass}__preview-card__self-service`}
borderRadiusSize="xxlarge"
>
<SelfServiceHeader
contactUrl={config?.org_info.contact_url || ""}
variant="preview"
/>
<SearchField
placeholder="Search by name"
onChange={noop}
disabled
/>
<div className={`${baseClass}__table`}>
<CategoriesMenu
categories={CATEGORIES_NAV_ITEMS}
queryParams={{
query: "",
order_direction: "asc",
order_key: "name",
page: 0,
per_page: 100,
}}
readOnly
/>
<BasicSoftwareTable
name={name}
displayName={displayName}
source={source}
iconUrl={iconUrl}
/>
</div>
</Card>
</Card>
)}
)}
renderTable={() => (
<BasicSoftwareTable
name={name}
displayName={displayName}
source={source}
iconUrl={iconUrl}
/>
)}
/>
<div className="modal-cta-wrap">
<Button onClick={onCancel}>Done</Button>
</div>
@@ -5,49 +5,20 @@
gap: $pad-large;
}
&__preview {
img {
width: 100%;
}
}
.modal-cta-wrap {
margin-top: 0; // Defer to new gap styling
}
&__preview-card {
max-height: 300px;
overflow: hidden;
.card-header__header {
font-size: initial;
}
&__self-service {
width: 515px;
height: 310px;
@include vertical-card-layout;
}
.categories-menu {
top: 0;
width: 200px;
}
.data-table__table {
width: 325px;
}
}
&__table {
display: flex;
flex-direction: row;
max-height: 290px;
padding: $pad-xlarge 0 0;
}
&__disabled-overlay {
position: absolute;
width: 540px;
height: 335px;
z-index: 1000;
&__self-service-preview--mobile {
// Different offsets than edit modal
top: -94px;
left: 175px;
gap: $pad-small;
}
}