diff --git a/frontend/components/EmptyState/_styles.scss b/frontend/components/EmptyState/_styles.scss index d2f4dfdaa20..7b9961dd83f 100644 --- a/frontend/components/EmptyState/_styles.scss +++ b/frontend/components/EmptyState/_styles.scss @@ -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; diff --git a/frontend/interfaces/secrets.ts b/frontend/interfaces/variables.ts similarity index 63% rename from frontend/interfaces/secrets.ts rename to frontend/interfaces/variables.ts index 5db7b844049..c624d400453 100644 --- a/frontend/interfaces/secrets.ts +++ b/frontend/interfaces/variables.ts @@ -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; } diff --git a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx index 873b0d90036..93fac9d810e 100644 --- a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx @@ -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"; diff --git a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tests.tsx b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tests.tsx index 4c9af299274..4e8ba86a4a8 100644 --- a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tests.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tests.tsx @@ -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) => { @@ -114,6 +112,12 @@ const getTestLocation = (status: ScriptBatchStatus): ScriptsLocation => ({ search: `?status=${status}`, }); +const EMPTY_STATE_TEXT: Record = { + 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, @@ -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(); }; diff --git a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tsx b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tsx index b4431877403..121bc24f850 100644 --- a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tsx @@ -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"; @@ -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"; @@ -34,19 +36,36 @@ const STATUS_BY_INDEX: ScriptBatchStatus[] = [ "finished", ]; -export const EMPTY_STATE_DETAILS: Record = { - 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 = { + 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 getEmptyState = (status: ScriptBatchStatus) => { return ( ); diff --git a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptLibrary/ScriptLibrary.tests.tsx b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptLibrary/ScriptLibrary.tests.tsx new file mode 100644 index 00000000000..93b38d8032b --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptLibrary/ScriptLibrary.tests.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptLibrary/ScriptLibrary.tsx b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptLibrary/ScriptLibrary.tsx index 8e5d318df1b..28c1c348542 100644 --- a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptLibrary/ScriptLibrary.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptLibrary/ScriptLibrary.tsx @@ -19,6 +19,8 @@ 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"; @@ -26,8 +28,8 @@ 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"; @@ -183,19 +185,34 @@ const ScriptLibrary = ({ router, teamId, location }: IScriptLibraryProps) => { ); + const canUploadScripts = !isTechnician; + return (
{config.server_settings.scripts_disabled && renderScriptsDisabledBanner()} {renderScriptsList()} - {!isLoading && - currentPage === 0 && - !scripts?.length && - (isTechnician ? ( - - ) : ( - setShowAddScriptModal(true)} /> - ))} + {!isLoading && !isError && currentPage === 0 && !scripts?.length && ( + ( + + )} + /> + ) : undefined + } + /> + )} {showDeleteScriptModal && selectedScript.current && ( { +export const SCRIPT_UPLOADER_EMPTY_STATE_TEXT = ( + <> + Upload shell (.sh) or Python (.py) for macOS and Linux, +
+ 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, @@ -73,5 +84,3 @@ const getWhen = (summary: IScriptBatchSummaryV2) => { return null; } }; - -export default getWhen; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tests.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tests.tsx index 1adbdc2a90d..29463450730 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tests.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tests.tsx @@ -60,7 +60,7 @@ describe("BootstrapPackage", () => { await waitFor(() => { expect( - screen.getByText(/turn on automatic enrollment/) + screen.getByText(/Turn on MDM and automatic enrollment/) ).toBeInTheDocument(); }); expect( @@ -83,7 +83,7 @@ describe("BootstrapPackage", () => { await waitFor(() => { expect( - screen.getByText(/turn on automatic enrollment/) + screen.getByText(/Turn on MDM and automatic enrollment/) ).toBeInTheDocument(); }); }); @@ -105,7 +105,7 @@ describe("BootstrapPackage", () => { await waitFor(() => { expect( - screen.getByText(/turn on automatic enrollment/) + screen.getByText(/Turn on MDM and automatic enrollment/) ).toBeInTheDocument(); }); }); diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx index ba60550dc50..e28108d1185 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx @@ -220,7 +220,14 @@ const BootstrapPackage = ({ + Turn on MDM and automatic enrollment to deploy a custom bootstrap + package. +
+ Supported on macOS. + + } primaryButton={ - {copyMessage && copiedSecretName === secret.name && ( + {copyMessage && copiedVariableName === variable.name && ( {`${copyMessage} `} @@ -152,7 +156,7 @@ const Variables = () => { variant="icon" onClick={(e: React.MouseEvent) => { e.stopPropagation(); - onClickDeleteSecret(secret); + onClickDeleteVariable(variable); }} > <> @@ -202,13 +206,20 @@ const Variables = () => {
( - )} @@ -219,7 +230,7 @@ const Variables = () => { {showAddModal && ( setShowAddModal(false)} - onSave={onSaveSecret} + onSave={onSaveVariable} /> )} @@ -236,7 +247,7 @@ const Variables = () => {