Skip to content
Merged
2 changes: 1 addition & 1 deletion frontend/components/EmptyState/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ $ghost-cell-padding-x: $pad-large; // 24px, matches Figma
// Form variant — ghost form fields with a save button
// ----------------------------------------------------------
&--form {
height: 320px;
height: $ghost-table-height;

.empty-state__ghost-table {
display: block;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
export interface ISecret {
export interface IVariable {
id: number;
name: string;
created_at: string;
updated_at: string;
}

export interface ISecretPayload {
export interface IVariablePayload {
name: string;
value: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import TabNav from "components/TabNav";
import TabText from "components/TabText";
import ViewAllHostsLink from "components/ViewAllHostsLink";

import getWhen from "../helpers";
import { getWhen } from "../helpers";
import CancelScriptBatchModal from "../components/CancelScriptBatchModal";
import ScriptBatchHostsTable from "./components/ScriptBatchHostsTable";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ import { ScriptBatchStatus } from "interfaces/script";

import { createMockBatchScriptSummary } from "__mocks__/scriptMock";

import ScriptBatchProgress, {
EMPTY_STATE_DETAILS,
} from "./ScriptBatchProgress";
import ScriptBatchProgress from "./ScriptBatchProgress";
import { ScriptsLocation } from "../../Scripts";

const waitForLoadingToFinish = async (container: HTMLElement) => {
Expand Down Expand Up @@ -114,6 +112,12 @@ const getTestLocation = (status: ScriptBatchStatus): ScriptsLocation => ({
search: `?status=${status}`,
});

const EMPTY_STATE_TEXT: Record<ScriptBatchStatus, string> = {
started: "Scripts running on multiple hosts will appear here.",
scheduled: "Scheduled scripts will appear here.",
finished: "Completed or canceled batch scripts will appear here.",
};

const testTabURLNavAndEmpty = async (status: ScriptBatchStatus) => {
const render = createCustomRenderer({
withBackendMock: true,
Expand Down Expand Up @@ -144,7 +148,7 @@ const testTabURLNavAndEmpty = async (status: ScriptBatchStatus) => {

await waitForLoadingToFinish(container);

expect(screen.getByText(EMPTY_STATE_DETAILS[status])).toBeInTheDocument();
expect(screen.getByText(EMPTY_STATE_TEXT[status])).toBeInTheDocument();
cleanup();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import scriptsAPI, {
import { isValidScriptBatchStatus, ScriptBatchStatus } from "interfaces/script";

import { COLORS } from "styles/var/colors";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";

import Spinner from "components/Spinner";
import ProgressBar from "components/ProgressBar";
Expand All @@ -22,9 +23,10 @@ import PaginatedList, { IPaginatedListHandle } from "components/PaginatedList";
import ListItem from "components/ListItem";
import Icon from "components/Icon/Icon";
import EmptyState from "components/EmptyState";
import CustomLink from "components/CustomLink";

import { IScriptsCommonProps } from "../../ScriptsNavItems";
import getWhen from "../../helpers";
import { getWhen } from "../../helpers";

const baseClass = "script-batch-progress";

Expand All @@ -34,19 +36,36 @@ const STATUS_BY_INDEX: ScriptBatchStatus[] = [
"finished",
];

export const EMPTY_STATE_DETAILS: Record<ScriptBatchStatus, string> = {
started: "When a script is run on multiple hosts, progress will appear here.",
scheduled:
"When a script is scheduled to run in the future, it will appear here.",
finished:
"When a batch script is completed or canceled, historical results will appear here.",
const EMPTY_STATE_DETAILS: Record<ScriptBatchStatus, JSX.Element> = {
started: (
<>
Scripts running on multiple hosts will appear here. <br />
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/batch-scripts`}
newTab
text="Learn more about batch scripts"
/>
</>
),
scheduled: (
<>
Scheduled scripts will appear here.
<br />
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/batch-scripts`}
newTab
text="Learn more about batch scripts"
/>
</>
),
finished: <>Completed or canceled batch scripts will appear here.</>,
};

const getEmptyState = (status: ScriptBatchStatus) => {
return (
<EmptyState
variant="list"
header={`No batch scripts ${status} for this fleet`}
header={`No batch scripts ${status}`}
info={EMPTY_STATE_DETAILS[status]}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import {
baseUrl,
createCustomRenderer,
createMockRouter,
} from "test/test-utils";
import mockServer from "test/mock-server";
import { http, HttpResponse } from "msw";

import ScriptLibrary from "./ScriptLibrary";
import { ScriptsLocation } from "../../Scripts";

const mockRouter = createMockRouter();

const mockLocation: ScriptsLocation = {
pathname: "/controls/scripts/library",
query: {},
search: "",
};

const emptyScriptsHandler = http.get(baseUrl("/scripts"), () =>
HttpResponse.json({
scripts: [],
meta: { has_next_results: false, has_previous_results: false },
})
);

const baseProps = {
router: mockRouter,
teamId: 1,
location: mockLocation,
};

describe("ScriptLibrary empty state", () => {
it("renders Upload CTA and info text for global admin", async () => {
mockServer.use(emptyScriptsHandler);

const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isGlobalAdmin: true,
config: {
server_settings: { scripts_disabled: false },
},
},
},
});

render(<ScriptLibrary {...baseProps} />);

await waitFor(() => {
expect(screen.getByText("No scripts")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /upload/i })).toBeInTheDocument();
expect(
screen.getByText(/Upload shell \(.sh\) or Python \(.py\)/i)
).toBeInTheDocument();
});

it("renders Upload CTA even when scripts are disabled (managing library is still allowed)", async () => {
mockServer.use(emptyScriptsHandler);

const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isGlobalAdmin: true,
config: {
server_settings: { scripts_disabled: true },
},
},
},
});

render(<ScriptLibrary {...baseProps} />);

await waitFor(() => {
expect(screen.getByText("No scripts")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /upload/i })).toBeInTheDocument();
});

it("hides Upload CTA and info text for global technician", async () => {
mockServer.use(emptyScriptsHandler);

const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isGlobalTechnician: true,
config: {
server_settings: { scripts_disabled: false },
},
},
},
});

render(<ScriptLibrary {...baseProps} />);

await waitFor(() => {
expect(screen.getByText("No scripts")).toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: /upload/i })
).not.toBeInTheDocument();
expect(
screen.queryByText(/Upload shell \(.sh\) or Python \(.py\)/i)
).not.toBeInTheDocument();
});

it("hides Upload CTA and info text for team technician", async () => {
mockServer.use(emptyScriptsHandler);

const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isTeamTechnician: true,
config: {
server_settings: { scripts_disabled: false },
},
},
},
});

render(<ScriptLibrary {...baseProps} />);

await waitFor(() => {
expect(screen.getByText("No scripts")).toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: /upload/i })
).not.toBeInTheDocument();
expect(
screen.queryByText(/Upload shell \(.sh\) or Python \(.py\)/i)
).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,17 @@ import Spinner from "components/Spinner";
import Pagination from "components/Pagination";
import SectionHeader from "components/SectionHeader";
import EmptyState from "components/EmptyState";
import Button from "components/buttons/Button";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";

import UploadList from "../../../../../components/UploadList";
import DeleteScriptModal from "../../components/DeleteScriptModal";
import EditScriptModal from "../../components/EditScriptModal";
import ScriptUploadModal from "../../components/ScriptUploadModal";
import ScriptListHeading from "../../components/ScriptListHeading";
import ScriptListItem from "../../components/ScriptListItem";
import ScriptUploader from "../../components/ScriptUploader";
import { IScriptsCommonProps } from "../../ScriptsNavItems";
import { SCRIPT_UPLOADER_EMPTY_STATE_TEXT } from "../../helpers";

const baseClass = "script-library";

Expand Down Expand Up @@ -183,19 +185,34 @@ const ScriptLibrary = ({ router, teamId, location }: IScriptLibraryProps) => {
</InfoBanner>
);

const canUploadScripts = !isTechnician;

return (
<div className={baseClass}>
<SectionHeader title="Library" alignLeftHeaderVertically />
{config.server_settings.scripts_disabled && renderScriptsDisabledBanner()}
{renderScriptsList()}
{!isLoading &&
currentPage === 0 &&
!scripts?.length &&
(isTechnician ? (
<EmptyState variant="header-list" header="No scripts uploaded" />
) : (
<ScriptUploader onButtonClick={() => setShowAddScriptModal(true)} />
))}
{!isLoading && !isError && currentPage === 0 && !scripts?.length && (
<EmptyState
variant="header-list"
header="No scripts"
info={canUploadScripts ? SCRIPT_UPLOADER_EMPTY_STATE_TEXT : undefined}
primaryButton={
canUploadScripts ? (
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<Button
onClick={() => setShowAddScriptModal(true)}
disabled={disableChildren}
>
Upload
</Button>
)}
/>
) : undefined
Comment thread
claude[bot] marked this conversation as resolved.
}
/>
)}
{showDeleteScriptModal && selectedScript.current && (
<DeleteScriptModal
scriptName={selectedScript.current?.name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from "react";

import FileUploader, { ISupportedGraphicNames } from "components/FileUploader";
import { getFileDetails } from "utilities/file/fileUtils";
import { SCRIPT_UPLOADER_TEXT } from "../../helpers";

const baseClass = "script-uploader";

Expand Down Expand Up @@ -46,7 +47,7 @@ const ScriptPackageUploader = ({
<FileUploader
className={baseClass}
graphicName={graphicName}
message="Shell (.sh) or Python (.py) for macOS and Linux, or PowerShell (.ps1) for Windows"
message={SCRIPT_UPLOADER_TEXT}
title="Upload script"
accept=".sh,.py,.ps1"
onFileUpload={onFileSelect}
Expand Down
15 changes: 12 additions & 3 deletions frontend/pages/ManageControlsPage/Scripts/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ import { IScriptBatchSummaryV2 } from "services/entities/scripts";

import { isDateTimePast } from "utilities/helpers";

const getWhen = (summary: IScriptBatchSummaryV2) => {
export const SCRIPT_UPLOADER_EMPTY_STATE_TEXT = (
<>
Upload shell (.sh) or Python (.py) for macOS and Linux,
<br />
or PowerShell (.ps1) for Windows.
</>
);

export const SCRIPT_UPLOADER_TEXT =
"Shell (.sh) or Python (.py) for macOS and Linux, or PowerShell (.ps1) for Windows";

export const getWhen = (summary: IScriptBatchSummaryV2) => {
const {
batch_execution_id: id,
not_before,
Expand Down Expand Up @@ -73,5 +84,3 @@ const getWhen = (summary: IScriptBatchSummaryV2) => {
return null;
}
};
Comment thread
claude[bot] marked this conversation as resolved.

export default getWhen;
Loading
Loading