fleet/frontend/pages/ManageControlsPage/Scripts/cards/ScriptLibrary/ScriptLibrary.tsx
Scott Gress 81f589d661
Update add script UI (#34349)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #32632

# Details

This PR updates the Script Library page in the following ways:
* When no scripts are uploaded for a team, it shows the "Add script" UI
with a button that opens a new "Add Script" modal
* When scripts are uploaded, the "Add script" button is instead added to
the header of the scripts list, and clicking it opens that modal

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [ ] Added/updated automated tests
working on this

- [X] QA'd all new/changed functionality manually
- [X] Test empty state: go to controls/scripts/library for a team with
no scripts. Clicking "upload" button in empty state should open the add
script modal.
- [X] In the modal, select a .ps1 script. Should not see additional
text.
- [X] Close modal without uploading. Re-open. File field should be
cleared & upload button visible again.
- [X] Select a .sh script. Should see additional text about macOS and
Linux.
  - [X] Add script. Make sure script saves and modal closes.
- [X] Once script has been added, make sure empty state is gone and "Add
script" button is at the top of the list.
- [X] Go to /controls/os-settings/custom-settings for a team with no
profiles uploaded. Make sure empty state text styles match the empty
state for script uploads.
- [X] Open modal to add profile. Make sure upload text styles match the
script upload modal.
- [X] Enable GitOps mode. Go to controls/scripts/library for a team with
scripts added. Make sure new "Add script" button is disabled w/ standard
tooltip in GitOps mode.

Scripts empty state:

<img width="697" height="352" alt="image"
src="https://github.com/user-attachments/assets/32f0f246-bddb-4bb7-bc39-48d9978de9fa"
/>

Scripts uploader:

<img width="745" height="590" alt="image"
src="https://github.com/user-attachments/assets/f82414e2-9318-4543-b5ca-41e759662587"
/>

Scripts uploader with .sh

<img width="750" height="539" alt="image"
src="https://github.com/user-attachments/assets/0b989067-921a-4d18-93ed-09aac90fc9cb"
/>

Scripts table:

<img width="686" height="256" alt="image"
src="https://github.com/user-attachments/assets/848f1b56-6e9e-48d4-9a03-6fdf5427301e"
/>

Profiles empty state:

<img width="700" height="377" alt="image"
src="https://github.com/user-attachments/assets/8f92bcd9-2215-41f6-a540-4774f7e9542b"
/>

Profiles uploader:

<img width="707" height="682" alt="image"
src="https://github.com/user-attachments/assets/eef216af-3447-48e7-882a-e42e888e1c17"
/>
2025-10-17 10:49:59 -05:00

216 lines
6.2 KiB
TypeScript

import React, { useCallback, useContext, useRef, useState } from "react";
import { AxiosError } from "axios";
import { useQuery } from "react-query";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { IScript } from "interfaces/script";
import scriptAPI, {
IListScriptsQueryKey,
IScriptsResponse,
} from "services/entities/scripts";
import DataError from "components/DataError";
import InfoBanner from "components/InfoBanner";
import Spinner from "components/Spinner";
import Pagination from "components/Pagination";
import SectionHeader from "components/SectionHeader";
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";
const baseClass = "script-library";
const SCRIPTS_PER_PAGE = 10;
const DEFAULT_PAGE = 0;
export type IScriptLibraryProps = IScriptsCommonProps;
const ScriptLibrary = ({ router, teamId, location }: IScriptLibraryProps) => {
const currentPage = location.query.page
? parseInt(location.query.page, 10)
: DEFAULT_PAGE;
const { isPremiumTier } = useContext(AppContext);
const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false);
const [showEditScriptModal, setShowEditScriptModal] = useState(false);
const [showAddScriptModal, setShowAddScriptModal] = useState(false);
const selectedScript = useRef<IScript | null>(null);
const {
data: { scripts, meta } = {},
isLoading,
isError,
refetch: refetchScripts,
} = useQuery<
IScriptsResponse,
AxiosError,
IScriptsResponse,
IListScriptsQueryKey[]
>(
[
{
scope: "scripts",
team_id: teamId,
page: currentPage,
per_page: SCRIPTS_PER_PAGE,
},
],
({ queryKey: [{ team_id, page, per_page }] }) =>
scriptAPI.getScripts({ team_id, page, per_page }),
{
...DEFAULT_USE_QUERY_OPTIONS,
staleTime: 3000,
}
);
// pagination controls
const path = PATHS.CONTROLS_SCRIPTS_LIBRARY;
const queryString = isPremiumTier ? `?team_id=${teamId}&` : "?";
const onPrevPage = useCallback(() => {
router.push(path.concat(`${queryString}page=${currentPage - 1}`));
}, [router, path, currentPage, queryString]);
const onNextPage = useCallback(() => {
router.push(path.concat(`${queryString}page=${currentPage + 1}`));
}, [router, path, currentPage, queryString]);
const { config } = useContext(AppContext);
if (!config) return null;
const onClickScript = (script: IScript) => {
selectedScript.current = script;
setShowEditScriptModal(true);
};
const onEditScript = (script: IScript) => {
selectedScript.current = script;
setShowEditScriptModal(true);
};
const onExitEditScript = () => {
selectedScript.current = null;
setShowEditScriptModal(false);
};
const onClickDelete = (script: IScript) => {
selectedScript.current = script;
setShowDeleteScriptModal(true);
};
const onCancelDelete = () => {
setShowDeleteScriptModal(false);
selectedScript.current = null;
};
const onDeleteScript = () => {
selectedScript.current = null;
setShowDeleteScriptModal(false);
refetchScripts();
};
const onUploadScript = () => {
refetchScripts();
};
const renderScriptsList = () => {
if (isLoading) {
return <Spinner />;
}
if (isError) {
return <DataError />;
}
if (currentPage === 0 && !scripts?.length) {
return null;
}
const headingComponent = () => (
<ScriptListHeading onClickAddScript={() => setShowAddScriptModal(true)} />
);
return (
<>
<UploadList
keyAttribute="id"
listItems={scripts || []}
HeadingComponent={headingComponent}
ListItemComponent={({ listItem }) => (
<ScriptListItem
script={listItem}
onDelete={onClickDelete}
onClickScript={onClickScript}
onEdit={onEditScript}
/>
)}
/>
<Pagination
disablePrev={isLoading || !meta?.has_previous_results}
disableNext={isLoading || !meta?.has_next_results}
hidePagination={
!isLoading && !meta?.has_previous_results && !meta?.has_next_results
}
onPrevPage={onPrevPage}
onNextPage={onNextPage}
/>
</>
);
};
const renderScriptsDisabledBanner = () => (
<InfoBanner color="yellow">
<div>
<b>Running scripts is disabled in organization settings.</b> You can
still manage your library of macOS and Windows scripts below.
</div>
</InfoBanner>
);
return (
<div className={baseClass}>
<SectionHeader title="Library" alignLeftHeaderVertically />
{config.server_settings.scripts_disabled && renderScriptsDisabledBanner()}
{renderScriptsList()}
{!isLoading && currentPage === 0 && !scripts?.length && (
<ScriptUploader onButtonClick={() => setShowAddScriptModal(true)} />
)}
{showDeleteScriptModal && selectedScript.current && (
<DeleteScriptModal
scriptName={selectedScript.current?.name}
scriptId={selectedScript.current?.id}
onCancel={onCancelDelete}
afterDelete={onDeleteScript}
/>
)}
{showEditScriptModal && selectedScript.current && (
<EditScriptModal
scriptId={selectedScript.current.id}
scriptName={selectedScript.current.name}
onExit={onExitEditScript}
/>
)}
{showAddScriptModal && (
<ScriptUploadModal
currentTeamId={teamId}
onExit={() => setShowAddScriptModal(false)}
onSubmit={() => {
setShowAddScriptModal(false);
refetchScripts();
}}
/>
)}
</div>
);
};
export default ScriptLibrary;