Software title page > View YAML: Simplify copy (#40353)

For the following quick win:
- https://github.com/fleetdm/fleet/issues/40354
This commit is contained in:
Noah Talerman
2026-02-25 18:43:23 -05:00
committed by GitHub
parent 53ea98cb74
commit 0b89f16456
6 changed files with 38 additions and 495 deletions
@@ -17,6 +17,7 @@ import FileProgressModal from "components/FileProgressModal";
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import InfoBanner from "components/InfoBanner";
import CategoriesEndUserExperienceModal from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal";
import PackageForm from "pages/SoftwarePage/components/forms/PackageForm";
@@ -170,6 +171,13 @@ const SoftwareCustomPackage = ({
return (
<>
{gitOpsModeEnabled && (
<InfoBanner color="grey" borderRadius="medium">
Add custom packages in GitOps mode so Fleet can host your software.
After adding, copy its SHA-256 hash into your YAML so the next
GitOps workflow doesn&apos;t delete it.
</InfoBanner>
)}
<PackageForm
labels={labels || []}
showSchemaButton={!isSidePanelOpen}
@@ -3,3 +3,7 @@
margin-top: $pad-xxxlarge;
}
}
.info-banner {
margin-bottom: $pad-xlarge;
}
@@ -1,24 +1,19 @@
import React, { useContext } from "react";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import { getExtensionFromFileName } from "utilities/file/fileUtils";
import FileSaver from "file-saver";
import { ISoftwarePackage } from "interfaces/software";
import softwareAPI from "services/entities/software";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import InfoBanner from "components/InfoBanner";
import CustomLink from "components/CustomLink";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Editor from "components/Editor";
import { hyphenateString } from "utilities/strings/stringUtils";
import { createPackageYaml, renderDownloadFilesText } from "./helpers";
import createPackageYaml from "./helpers";
const baseClass = "view-yaml-modal";
@@ -34,183 +29,39 @@ interface IViewYamlModalProps {
isIosOrIpadosApp?: boolean;
}
interface HandleDownloadParams {
evt: React.MouseEvent;
content?: string;
downloadUrl?: string;
filename: string;
filetype: string;
errorMsg: string;
}
const ViewYamlModal = ({
softwareTitleName,
softwareTitleId: softwareId,
teamId,
iconUrl,
displayName,
softwarePackage,
onExit,
isScriptPackage = false,
isIosOrIpadosApp = false,
}: IViewYamlModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const { config } = useContext(AppContext);
const repositoryUrl = config?.gitops?.repository_url;
const { name, version, url, hash_sha256: sha256 } = softwarePackage;
// Script packages (.sh and .ps1) should not expose install_script,
// post_install_script, uninstall_script, or pre_install_query fields
const preInstallQuery = !isScriptPackage
? softwarePackage.pre_install_query
: undefined;
const installScript = !isScriptPackage
? softwarePackage.install_script
: undefined;
const postInstallScript = !isScriptPackage
? softwarePackage.post_install_script
: undefined;
const uninstallScript = !isScriptPackage
? softwarePackage.uninstall_script
: undefined;
const packageYaml = createPackageYaml({
softwareTitle: softwareTitleName,
packageName: name,
version,
url,
sha256,
preInstallQuery,
installScript,
postInstallScript,
uninstallScript,
iconUrl: iconUrl || null,
displayName,
isScriptPackage,
});
// Generic download handler
const handleDownload = async ({
evt,
content,
downloadUrl,
filename,
filetype,
errorMsg,
}: HandleDownloadParams) => {
evt.preventDefault();
try {
if (content) {
const file = new window.File([content], filename, { type: filetype });
FileSaver.saveAs(file);
} else if (downloadUrl) {
const response = await fetch(downloadUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
FileSaver.saveAs(blob, filename);
} else {
throw new Error("No content or URL provided");
}
} catch (err) {
renderFlash("error", errorMsg);
}
return false;
};
const hyphenatedSoftwareTitle = hyphenateString(softwareTitleName);
const onDownloadPreInstallQuery = (evt: React.MouseEvent) => {
const softwareExtension = getExtensionFromFileName(name);
const preInstallQueryContent = `- name: "[Pre-install software] ${softwareTitleName} (${softwareExtension})"\n query: ${preInstallQuery}`;
handleDownload({
evt,
content: preInstallQueryContent,
filename: `pre-install-query-${hyphenatedSoftwareTitle}.yml`,
filetype: "text/yml",
errorMsg:
"Your pre-install query could not be downloaded. Please create YAML file (.yml) manually.",
});
};
const onDownloadPostInstallScript = (evt: React.MouseEvent) => {
handleDownload({
evt,
content: postInstallScript,
filename: `post-install-${hyphenatedSoftwareTitle}.sh`,
filetype: "text/sh",
errorMsg:
"Your post-install script could not be downloaded. Please create script file (.sh) manually.",
});
};
const onDownloadInstallScript = (evt: React.MouseEvent) => {
handleDownload({
evt,
content: installScript,
filename: `install-${hyphenatedSoftwareTitle}.sh`,
filetype: "text/sh",
errorMsg:
"Your install script could not be downloaded. Please create script file (.sh) manually.",
});
};
const onDownloadUninstallScript = (evt: React.MouseEvent) => {
handleDownload({
evt,
content: uninstallScript,
filename: `uninstall-${hyphenatedSoftwareTitle}.sh`,
filetype: "text/sh",
errorMsg:
"Your uninstall script could not be downloaded. Please create script file (.sh) manually.",
});
};
const onDownloadIcon = async (evt: React.MouseEvent) => {
evt.preventDefault();
try {
// Get icon blob + create filename
const response = await softwareAPI.getSoftwareIcon(softwareId, teamId);
// Different from icon's original filename as we are suggesting a standard name used in YAML
const filename = `${hyphenatedSoftwareTitle}-icon.png`;
// Save the file
FileSaver.saveAs(response.data, filename);
} catch (err) {
renderFlash(
"error",
"Your icon could not be downloaded. Please download the image manually."
);
}
};
return (
<Modal className={baseClass} title="YAML" onExit={onExit}>
<>
<InfoBanner className={`${baseClass}__info-banner`}>
<p>
To complete your GitOps configuration, follow the instructions
below. If the YAML is not added, new installers will be deleted on
the next GitOps run, and edited installers will cause the GitOps run
to fail.&nbsp;
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/yaml-packages`}
text="How to use YAML"
newTab
multiline
variant="banner-link"
/>
</p>
</InfoBanner>
{repositoryUrl && (
<p>
First, create the YAML file below and save it to your{" "}
<CustomLink url={repositoryUrl} text="repository" newTab />.
Manage in <CustomLink url={repositoryUrl} text="YAML" newTab />.
</p>
)}
<p>Make sure you reference the package YAML from your fleet YAML.</p>
<div className={`${baseClass}__form-fields`}>
<InputField
enableCopy
@@ -219,32 +70,23 @@ const ViewYamlModal = ({
label="Filename"
value={`${hyphenatedSoftwareTitle}.package.yml`}
/>
<Editor label="Contents" value={packageYaml} enableCopy />
<Editor
label="Contents"
value={packageYaml}
enableCopy
helpText={
<>
If you added advanced options, learn how to{" "}
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/yaml-packages`}
text="add them to your YAML"
newTab
/>
.
</>
}
/>
</div>
<p>
{renderDownloadFilesText({
preInstallQuery,
installScript,
postInstallScript,
uninstallScript,
iconUrl,
onClickPreInstallQuery: preInstallQuery
? onDownloadPreInstallQuery
: undefined,
onClickInstallScript: installScript
? onDownloadInstallScript
: undefined,
onClickPostInstallScript: postInstallScript
? onDownloadPostInstallScript
: undefined,
onClickUninstallScript: uninstallScript
? onDownloadUninstallScript
: undefined,
onClickIcon: iconUrl ? onDownloadIcon : undefined,
hasAdvancedOptionsAvailable: !isScriptPackage && !isIosOrIpadosApp,
isScriptPackage,
})}
</p>
<div className="modal-cta-wrap">
<Button onClick={onExit}>Done</Button>
</div>
@@ -1,14 +1,13 @@
.view-yaml-modal {
overflow-wrap: anywhere; // Prevent long overflow
.info-banner__info {
p {
margin: 0; // Undo weird top margin from info-banner component
}
.editor__label {
color: $core-fleet-black;
}
&__form-fields {
display: flex;
flex-direction: column;
gap: $pad-medium;
}
}
}
@@ -1,8 +1,6 @@
import { render, screen } from "@testing-library/react";
import { createMockSoftwarePackage } from "__mocks__/softwareMock";
import { noop } from "lodash";
import { createPackageYaml, renderDownloadFilesText } from "./helpers";
import createPackageYaml from "./helpers";
describe("createPackageYaml", () => {
const {
@@ -201,197 +199,3 @@ describe("createPackageYaml", () => {
path: ./icons/falcon-sensor-test-package-icon.png`);
});
});
describe("renderYamlHelperText", () => {
const {
pre_install_query: preInstallQuery,
install_script: installScript,
post_install_script: postInstallScript,
uninstall_script: uninstallScript,
} = createMockSoftwarePackage();
it("renders nothing if no scripts/queries are present", () => {
// Empty to simulate 'no items'
const { container } = render(renderDownloadFilesText({}));
expect(container).toBeEmptyDOMElement();
});
it("renders correctly with one item", () => {
// Only install_script present
render(
renderDownloadFilesText({ installScript, onClickInstallScript: noop })
);
expect(
screen.getByRole("button", { name: "install script" })
).toBeInTheDocument();
expect(
screen.getByText((content) =>
content.includes("add it to your repository using the path above.")
)
).toBeInTheDocument();
expect(screen.queryByText("and")).not.toBeInTheDocument();
});
it("renders correctly with two items", () => {
const { container } = render(
renderDownloadFilesText({
installScript,
uninstallScript,
onClickInstallScript: noop,
onClickUninstallScript: noop,
})
);
expect(
screen.getByRole("button", { name: "install script" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "uninstall script" })
).toBeInTheDocument();
// In "Next," and "Advanced options," only
const text = container.textContent ?? "";
const commaCount = (text.match(/,/g) || []).length;
expect(commaCount).toBe(2);
// No oxford comma for two items
expect(
screen.queryByText((content) => content.includes(", and"))
).not.toBeInTheDocument();
expect(
screen.getByText((content) =>
content.includes("add them to your repository using the paths above.")
)
).toBeInTheDocument();
});
it("renders correctly with all items", () => {
// All present (default)
const { container } = render(
renderDownloadFilesText({
preInstallQuery,
installScript,
uninstallScript,
postInstallScript,
onClickPreInstallQuery: noop,
onClickInstallScript: noop,
onClickUninstallScript: noop,
onClickPostInstallScript: noop,
})
);
expect(
screen.getByRole("button", { name: "pre-install query" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "install script" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "post-install script" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "uninstall script" })
).toBeInTheDocument();
// In "Next," "Advanced options," and 3 more commas
const text = container.textContent ?? "";
const commaCount = (text.match(/,/g) || []).length;
expect(commaCount).toBe(5);
// Oxford comma for four items
expect(
screen.queryByText((content) => content.includes(", and"))
).toBeInTheDocument();
expect(
screen.getByText((content) =>
content.includes("add them to your repository using the paths above.")
)
).toBeInTheDocument();
});
it("renders comma correctly for three items (with Oxford comma)", () => {
// pre_install_query, install_script, uninstall_script present
const { container } = render(
renderDownloadFilesText({
preInstallQuery,
installScript,
uninstallScript,
onClickPreInstallQuery: noop,
onClickInstallScript: noop,
onClickUninstallScript: noop,
})
);
expect(
screen.getByRole("button", { name: "pre-install query" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "install script" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "uninstall script" })
).toBeInTheDocument();
// In "Next," "Advanced options," and 2 more commas
const text = container.textContent ?? "";
const commaCount = (text.match(/,/g) || []).length;
expect(commaCount).toBe(4);
// Oxford comma for three items
expect(
screen.getByText((content) => content.includes(", and"))
).toBeInTheDocument();
expect(
screen.getByText((content) =>
content.includes("add them to your repository using the paths above.")
)
).toBeInTheDocument();
});
it("does not render advanced options help text when hasAdvancedOptionsAvailable is false", () => {
render(
renderDownloadFilesText({
installScript: "echo install",
onClickInstallScript: noop,
hasAdvancedOptionsAvailable: false,
})
);
// Should not show the "Advanced options" instructional text
expect(screen.queryByText(/If you edited/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Advanced options/i)).not.toBeInTheDocument();
});
it("does not render script-only fields for script packages", () => {
// Script packages should not show download links for install_script,
// post_install_script, uninstall_script, or pre_install_query
const { container } = render(
renderDownloadFilesText({
preInstallQuery,
installScript,
postInstallScript,
uninstallScript,
onClickPreInstallQuery: noop,
onClickInstallScript: noop,
onClickPostInstallScript: noop,
onClickUninstallScript: noop,
isScriptPackage: true,
})
);
// Should not render any download buttons for script-only fields
expect(
screen.queryByRole("button", { name: "pre-install query" })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "install script" })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "post-install script" })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "uninstall script" })
).not.toBeInTheDocument();
// Container should be empty since no items should be rendered
expect(container).toBeEmptyDOMElement();
});
});
@@ -1,23 +1,7 @@
import React, { MouseEvent } from "react";
import Button from "components/buttons/Button";
import { hyphenateString } from "utilities/strings/stringUtils";
interface RenderYamlHelperText {
installScript?: string;
uninstallScript?: string;
preInstallQuery?: string;
postInstallScript?: string;
iconUrl?: string | null;
onClickPreInstallQuery?: (evt: MouseEvent) => void;
onClickInstallScript?: (evt: MouseEvent) => void;
onClickPostInstallScript?: (evt: MouseEvent) => void;
onClickUninstallScript?: (evt: MouseEvent) => void;
onClickIcon?: (evt: MouseEvent) => void;
hasAdvancedOptionsAvailable?: boolean;
isScriptPackage?: boolean;
}
// Helper to join items with commas and Oxford comma before "and"
const joinWithCommasAnd = (
elements: { key: string; element: JSX.Element }[]
@@ -39,106 +23,6 @@ const joinWithCommasAnd = (
});
};
export const renderDownloadFilesText = ({
preInstallQuery,
installScript,
postInstallScript,
uninstallScript,
iconUrl,
onClickPreInstallQuery,
onClickInstallScript,
onClickPostInstallScript,
onClickUninstallScript,
onClickIcon,
hasAdvancedOptionsAvailable = true,
isScriptPackage = false,
}: RenderYamlHelperText): JSX.Element => {
const items: { key: string; element: JSX.Element }[] = [];
// Script packages (.sh and .ps1) should not expose install_script,
// post_install_script, uninstall_script, or pre_install_query fields
if (!isScriptPackage && preInstallQuery) {
items.push({
key: "pre-install-query",
element: (
<Button key="pre" variant="text-link" onClick={onClickPreInstallQuery}>
pre-install query
</Button>
),
});
}
if (!isScriptPackage && installScript) {
items.push({
key: "install-script",
element: (
<Button
key="install"
variant="text-link"
onClick={onClickInstallScript}
>
install script
</Button>
),
});
}
if (!isScriptPackage && uninstallScript) {
items.push({
key: "uninstall-script",
element: (
<Button
key="uninstall"
variant="text-link"
onClick={onClickUninstallScript}
>
uninstall script
</Button>
),
});
}
if (!isScriptPackage && postInstallScript) {
items.push({
key: "post-install-script",
element: (
<Button
key="post"
variant="text-link"
onClick={onClickPostInstallScript}
>
post-install script
</Button>
),
});
}
if (iconUrl) {
items.push({
key: "icon-url",
element: (
<Button key="post" variant="text-link" onClick={onClickIcon}>
icon
</Button>
),
});
}
if (items.length === 0) return <></>;
return (
<>
Next, download your {joinWithCommasAnd(items)} and add{" "}
{items.length === 1 ? "it" : "them"} to your repository using the{" "}
{items.length === 1 ? "path" : "paths"} above.
{hasAdvancedOptionsAvailable && (
<>
{" "}
If you edited <b>Advanced options</b>, download and replace the{" "}
{items.length === 1 ? "file" : "files"} in your repository with the
updated {items.length === 1 ? "one" : "ones"}.
</>
)}
</>
);
};
interface CreatePackageYamlParams {
softwareTitle: string;
packageName: string;
@@ -154,7 +38,7 @@ interface CreatePackageYamlParams {
isScriptPackage?: boolean;
}
export const createPackageYaml = ({
const createPackageYaml = ({
softwareTitle,
packageName,
version,
@@ -224,3 +108,5 @@ export const createPackageYaml = ({
return yaml.trim();
};
export default createPackageYaml;