FE: Refactor Self Service card into its own file (#32738)

This commit is contained in:
RachelElysia 2025-09-11 13:36:37 -04:00 committed by GitHub
parent 3d78aa8c33
commit c5d734f276
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 480 additions and 232 deletions

View file

@ -74,17 +74,6 @@ describe("SelfService", () => {
expect(screen.getAllByText("test3")).toHaveLength(2);
});
it("should render the contact link text for self-service section if contact url is provided", () => {
mockServer.use(customDeviceSoftwareHandler());
const render = createCustomRenderer({ withBackendMock: true });
render(<SelfService {...TEST_PROPS} router={createMockRouter()} />);
const link = screen.getByRole("link", { name: /reach out to IT/i });
expect(link).toHaveAttribute("href", "http://example.com");
});
it("renders installed status and 'Reinstall' action button and 'More' dropdown with 'installed' status and installed_versions", async () => {
mockServer.use(
customDeviceSoftwareHandler({

View file

@ -25,31 +25,16 @@ import deviceApi, {
} from "services/entities/device_user";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { getPathWithQueryParams } from "utilities/url";
import { getExtensionFromFileName } from "utilities/file/fileUtils";
import { SingleValue } from "react-select-5";
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import TableContainer from "components/TableContainer";
import EmptySoftwareTable from "pages/SoftwarePage/components/tables/EmptySoftwareTable";
import Card from "components/Card";
import CardHeader from "components/CardHeader";
import CustomLink from "components/CustomLink";
import DeviceUserError from "components/DeviceUserError";
import EmptyTable from "components/EmptyTable";
import Spinner from "components/Spinner";
import SearchField from "components/forms/fields/SearchField";
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
import SoftwareUninstallDetailsModal, {
ISWUninstallDetailsParentState,
} from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal";
import SoftwareInstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal";
import { VppInstallDetailsModal } from "components/ActivityDetails/InstallDetails/VppInstallDetailsModal/VppInstallDetailsModal";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import UpdatesCard from "./UpdatesCard/UpdatesCard";
import SelfServiceCard from "./SelfServiceCard/SelfServiceCard";
import SoftwareUpdateModal from "../SoftwareUpdateModal";
import UninstallSoftwareModal from "./UninstallSoftwareModal";
import SoftwareInstructionsModal from "./OpenSoftwareModal";
@ -57,12 +42,6 @@ import SoftwareInstructionsModal from "./OpenSoftwareModal";
import { generateSoftwareTableHeaders } from "./SelfServiceTableConfig";
import { getLastInstall } from "../../HostSoftwareLibrary/helpers";
import {
CATEGORIES_NAV_ITEMS,
filterSoftwareByCategory,
ICategory,
} from "./helpers";
import CategoriesMenu from "./CategoriesMenu";
import { getUiStatus } from "../helpers";
const baseClass = "software-self-service";
@ -150,10 +129,6 @@ const SoftwareSelfService = ({
}: ISoftwareSelfServiceProps) => {
const { renderFlash, renderMultiFlash } = useContext(NotificationContext);
const initialSortHeader = queryParams.order_key || "name";
const initialSortDirection = queryParams.order_direction || "asc";
const initialSortPage = queryParams.page || 0;
const [selfServiceData, setSelfServiceData] = useState<
IGetDeviceSoftwareResponse | undefined
>(undefined);
@ -505,48 +480,6 @@ const SoftwareSelfService = ({
[setSelectedHostSWUninstallDetails]
);
const onSearchQueryChange = (value: string) => {
router.push(
getPathWithQueryParams(pathname, {
query: value,
category_id: queryParams.category_id,
order_key: initialSortHeader,
order_direction: initialSortDirection,
page: 0, // Always reset to page 0 when searching
})
);
};
const onSortChange = ({ sortHeader, sortDirection }: ITableQueryData) => {
router.push(
getPathWithQueryParams(pathname, {
...queryParams,
order_key: sortHeader,
order_direction: sortDirection,
query: queryParams.query !== undefined ? queryParams.query : undefined,
category_id:
queryParams.category_id !== undefined
? queryParams.category_id
: undefined,
page: 0, // Always reset to page 0 when sorting
})
);
};
const onCategoriesDropdownChange = (
option: SingleValue<CustomOptionType>
) => {
router.push(
getPathWithQueryParams(pathname, {
category_id: option?.value !== "undefined" ? option?.value : undefined,
query: queryParams.query,
order_key: initialSortHeader,
order_direction: initialSortDirection,
page: 0, // Always reset to page 0 when searching
})
);
};
const onClickFailedUpdateStatus = (hostSoftware: IHostSoftware) => {
const lastInstall = getLastInstall(hostSoftware);
@ -582,28 +515,6 @@ const SoftwareSelfService = ({
onInstallOrUninstall();
};
const onClientSidePaginationChange = useCallback(
(page: number) => {
router.push(
getPathWithQueryParams(pathname, {
query: queryParams.query,
category_id: queryParams.category_id,
order_key: initialSortHeader,
order_direction: initialSortDirection,
page,
})
);
},
[
pathname,
queryParams.query,
queryParams.category_id,
initialSortDirection,
initialSortHeader,
router,
]
);
// TODO: handle empty state better, this is just a placeholder for now
// TODO: what should happen if query params are invalid (e.g., page is negative or exceeds the
// available results)?
@ -636,109 +547,6 @@ const SoftwareSelfService = ({
onClickOpenInstructionsAction,
]);
const renderSelfServiceCard = () => {
const renderHeaderFilters = () => (
<div className={`${baseClass}__header-filters`}>
<SearchField
placeholder="Search by name"
onChange={onSearchQueryChange}
defaultValue={queryParams.query}
/>
<DropdownWrapper
options={CATEGORIES_NAV_ITEMS.map((category: ICategory) => ({
...category,
value: String(category.id), // DropdownWrapper only accepts string
}))}
value={String(queryParams.category_id || 0)}
onChange={onCategoriesDropdownChange}
name="categories-dropdown"
className={`${baseClass}__categories-dropdown`}
/>
</div>
);
const renderCategoriesMenu = () => (
<CategoriesMenu
queryParams={queryParams}
categories={CATEGORIES_NAV_ITEMS}
/>
);
if (isLoading) {
return <Spinner />;
}
if (isError) {
return <DeviceUserError />; // Only shown on DeviceUserPage not HostDetailsPage
}
// No self-service software available hides categories menu and header filters
if ((isEmpty || !selfServiceData) && !isFetching) {
return (
<>
<EmptyTable
graphicName="empty-software"
header="No self-service software available yet"
info="Your organization didn't add any self-service software. If you need any, reach out to your IT department."
/>
</>
);
}
return (
<>
{renderHeaderFilters()}
<div className={`${baseClass}__table`}>
{renderCategoriesMenu()}
<TableContainer
columnConfigs={tableConfig}
data={filterSoftwareByCategory(
enhancedSoftware || [],
queryParams.category_id
)}
isLoading={isFetching}
defaultSortHeader={initialSortHeader}
defaultSortDirection={initialSortDirection}
onQueryChange={onSortChange} // Only used for sort
pageIndex={initialSortPage} // Client-side pagination with URL source of truth
disableNextPage={selfServiceData?.meta.has_next_results === false}
pageSize={DEFAULT_CLIENT_SIDE_PAGINATION}
searchQuery={queryParams.query} // Search is now client-side to reduce API calls
searchQueryColumn="name"
isClientSideFilter
isClientSidePagination
disableAutoResetPage // Prevents resetting page to 0 on data change when clicking install/uninstall
onClientSidePaginationChange={onClientSidePaginationChange}
emptyComponent={() => {
return isEmptySearch ? (
<EmptyTable
graphicName="empty-search-question"
header="No items match the current search criteria"
info={
<>
Not finding what you&apos;re looking for?{" "}
<CustomLink
url={contactUrl}
text="Reach out to IT"
newTab
/>
</>
}
/>
) : (
<EmptySoftwareTable />
);
}}
showMarkAllPages={false}
isAllPagesSelected={false}
disableTableHeader
disableCount
/>
</div>
</>
);
};
return (
<div className={baseClass}>
<UpdatesCard
@ -749,28 +557,20 @@ const SoftwareSelfService = ({
onClickUpdateAction={onClickUpdateAction}
onClickFailedUpdateStatus={onClickFailedUpdateStatus}
/>
<Card
className={`${baseClass}__self-service-card`}
borderRadiusSize="xxlarge"
paddingSize="xlarge"
includeShadow
>
<CardHeader
header="Self-service"
subheader={
<>
{SELF_SERVICE_SUBHEADER}{" "}
{contactUrl && (
<span>
If you need help,{" "}
<CustomLink url={contactUrl} text="reach out to IT" newTab />
</span>
)}
</>
}
/>
{renderSelfServiceCard()}
</Card>
<SelfServiceCard
contactUrl={contactUrl}
queryParams={queryParams}
enhancedSoftware={enhancedSoftware}
selfServiceData={selfServiceData}
tableConfig={tableConfig}
isLoading={isLoading}
isError={isError}
isFetching={isFetching}
isEmpty={isEmpty}
isEmptySearch={isEmptySearch}
router={router}
pathname={pathname}
/>
{showUninstallSoftwareModal && selectedSoftwareForUninstall.current && (
<UninstallSoftwareModal
softwareId={selectedSoftwareForUninstall.current.softwareId}

View file

@ -4,14 +4,14 @@ import classNames from "classnames";
import LinkWithContext from "components/LinkWithContext";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import { parseSelfServiceQueryParams } from "../SelfService";
import { ICategory } from "../helpers";
import { ICategory } from "../../helpers";
import { SelfServiceQueryParams } from "../SelfServiceCard";
const baseClass = "categories-menu";
export interface ICategoriesMenu {
categories: ICategory[];
queryParams: ReturnType<typeof parseSelfServiceQueryParams>;
queryParams: SelfServiceQueryParams;
className?: string;
}

View file

@ -0,0 +1,211 @@
// State is passed in through tableConfig which is tested in the parent component's tests (SelfService.tests.tsx)
import React from "react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createCustomRenderer, createMockRouter } from "test/test-utils";
import { createMockDeviceSoftware } from "__mocks__/deviceUserMock";
import SelfServiceCard, {
SelfServiceQueryParams,
ISelfServiceCardProps,
} from "./SelfServiceCard";
const createMockTableConfig = () => [
{
title: "Name",
accessor: "name",
disableHidden: false,
},
{
title: "Status",
accessor: "status",
disableHidden: false,
},
{
title: "Actions",
accessor: "actions",
disableHidden: false,
},
];
const DEFAULT_QUERY_PARAMS: SelfServiceQueryParams = {
page: 0,
query: "",
order_key: "name",
order_direction: "asc",
per_page: 20,
category_id: undefined,
};
const createTestProps = (
overrides: Partial<ISelfServiceCardProps> = {}
): ISelfServiceCardProps => ({
contactUrl: "http://example.com/contact",
queryParams: DEFAULT_QUERY_PARAMS,
enhancedSoftware: [
{ ...createMockDeviceSoftware({ name: "test1" }), ui_status: "installed" },
{ ...createMockDeviceSoftware({ name: "test2" }), ui_status: "installed" },
{ ...createMockDeviceSoftware({ name: "test3" }), ui_status: "installed" },
],
selfServiceData: {
count: 3,
software: [
createMockDeviceSoftware({ name: "test1" }),
createMockDeviceSoftware({ name: "test2" }),
createMockDeviceSoftware({ name: "test3" }),
],
meta: {
has_previous_results: false,
has_next_results: false,
},
},
tableConfig: createMockTableConfig(),
isLoading: false,
isError: false,
isFetching: false,
isEmpty: false,
isEmptySearch: false,
router: createMockRouter(),
pathname: "/device/software",
...overrides,
});
describe("SelfServiceCard", () => {
it("renders loading spinner when isLoading is true", () => {
const props = createTestProps({ isLoading: true });
const render = createCustomRenderer();
render(<SelfServiceCard {...props} />);
expect(screen.getByTestId("spinner")).toBeInTheDocument();
});
it("renders error state when isError is true", () => {
const props = createTestProps({ isError: true });
const render = createCustomRenderer();
render(<SelfServiceCard {...props} />);
expect(screen.getByText("Error loading software.")).toBeInTheDocument();
});
it("renders empty state when isEmpty is true", () => {
const props = createTestProps({
isEmpty: true,
enhancedSoftware: [],
selfServiceData: undefined,
isFetching: false,
});
const render = createCustomRenderer();
render(<SelfServiceCard {...props} />);
expect(
screen.getByText("No self-service software available yet")
).toBeInTheDocument();
expect(
screen.getByText(
/Your organization didnt add any self-service software./i
)
).toBeInTheDocument();
});
it("renders self-service card with header and subheader", () => {
const props = createTestProps();
const render = createCustomRenderer();
render(<SelfServiceCard {...props} />);
expect(screen.getByText("Self-service")).toBeInTheDocument();
expect(
screen.getByText(
/Install organization-approved apps provided by your IT department/
)
).toBeInTheDocument();
});
it("renders contact link when contactUrl is provided", () => {
const props = createTestProps({ contactUrl: "http://example.com/help" });
const render = createCustomRenderer();
render(<SelfServiceCard {...props} />);
const link = screen.getByRole("link", { name: /reach out to IT/i });
expect(link).toHaveAttribute("href", "http://example.com/help");
expect(link).toHaveAttribute("target", "_blank");
});
it("does not render contact link when contactUrl is empty", () => {
const props = createTestProps({ contactUrl: "" });
const render = createCustomRenderer();
render(<SelfServiceCard {...props} />);
expect(
screen.queryByRole("link", { name: /reach out to IT/i })
).not.toBeInTheDocument();
});
it("renders search field with correct placeholder and default value", () => {
const props = createTestProps({
queryParams: { ...DEFAULT_QUERY_PARAMS, query: "test search" },
});
const render = createCustomRenderer();
render(<SelfServiceCard {...props} />);
const searchField = screen.getByPlaceholderText("Search by name");
expect(searchField).toBeInTheDocument();
expect(searchField).toHaveValue("test search");
});
it("calls router.push when category dropdown changes", async () => {
const mockRouter = createMockRouter();
const props = createTestProps({ router: mockRouter });
const render = createCustomRenderer();
const user = userEvent.setup();
render(<SelfServiceCard {...props} />);
const dropdown = screen.getByRole("combobox");
await user.click(dropdown);
// Note: This test might need adjustment based on your dropdown implementation
expect(mockRouter.push).toHaveBeenCalled();
});
it("renders empty search state when isEmptySearch is true", () => {
const props = createTestProps({
isEmptySearch: true,
enhancedSoftware: [],
queryParams: { ...DEFAULT_QUERY_PARAMS, query: "nonexistent" },
});
const render = createCustomRenderer();
render(<SelfServiceCard {...props} />);
expect(screen.getByText("No items match your search")).toBeInTheDocument();
expect(
screen.getByText(/Not finding what you're looking for/)
).toBeInTheDocument();
const contactLink = screen.getAllByRole("link", {
name: /Reach out to IT/i,
});
expect(contactLink[0]).toHaveAttribute("href", props.contactUrl);
});
it("renders categories menu component", () => {
const props = createTestProps();
const render = createCustomRenderer();
render(<SelfServiceCard {...props} />);
expect(screen.getAllByText(/Browsers/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Communication/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Productivity/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Developer tools/i).length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,248 @@
import React, { useCallback } from "react";
import { InjectedRouter } from "react-router";
import { getPathWithQueryParams } from "utilities/url";
import { SingleValue } from "react-select-5";
import { IDeviceSoftwareWithUiStatus } from "interfaces/software";
import { IGetDeviceSoftwareResponse } from "services/entities/device_user";
import Card from "components/Card";
import CardHeader from "components/CardHeader";
import Spinner from "components/Spinner";
import EmptyTable from "components/EmptyTable";
import EmptySoftwareTable from "pages/SoftwarePage/components/tables/EmptySoftwareTable";
import TableContainer from "components/TableContainer";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import SearchField from "components/forms/fields/SearchField";
import DropdownWrapper, {
CustomOptionType,
} from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import CustomLink from "components/CustomLink";
import CategoriesMenu from "./CategoriesMenu/CategoriesMenu";
import { filterSoftwareByCategory, CATEGORIES_NAV_ITEMS } from "./../helpers";
const baseClass = "software-self-service";
export interface SelfServiceQueryParams {
query: string;
category_id?: number;
order_key: string;
order_direction: "asc" | "desc";
page: number;
per_page: number;
}
export interface ISelfServiceCardProps {
contactUrl: string;
queryParams: SelfServiceQueryParams;
enhancedSoftware: IDeviceSoftwareWithUiStatus[];
selfServiceData?: IGetDeviceSoftwareResponse;
tableConfig: any;
isLoading: boolean;
isError: boolean;
isFetching: boolean;
isEmpty: boolean;
isEmptySearch: boolean;
router: InjectedRouter;
pathname: string;
}
const SelfServiceCard = ({
contactUrl,
queryParams,
enhancedSoftware,
selfServiceData,
tableConfig,
isLoading,
isError,
isFetching,
isEmpty,
isEmptySearch,
router,
pathname,
}: ISelfServiceCardProps) => {
const initialSortHeader = queryParams.order_key || "name";
const initialSortDirection = queryParams.order_direction || "asc";
const initialSortPage = queryParams.page || 0;
const onClientSidePaginationChange = useCallback(
(page: number) => {
router.push(
getPathWithQueryParams(pathname, {
query: queryParams.query,
category_id: queryParams.category_id,
order_key: initialSortHeader,
order_direction: initialSortDirection,
page,
})
);
},
[
pathname,
queryParams.query,
queryParams.category_id,
initialSortDirection,
initialSortHeader,
router,
]
);
const onSearchQueryChange = (value: string) => {
router.push(
getPathWithQueryParams(pathname, {
query: value,
category_id: queryParams.category_id,
order_key: initialSortHeader,
order_direction: initialSortDirection,
page: 0, // Always reset to page 0 when searching
})
);
};
const onSortChange = ({ sortHeader, sortDirection }: ITableQueryData) => {
router.push(
getPathWithQueryParams(pathname, {
...queryParams,
order_key: sortHeader,
order_direction: sortDirection,
query: queryParams.query !== undefined ? queryParams.query : undefined,
category_id:
queryParams.category_id !== undefined
? queryParams.category_id
: undefined,
page: 0, // Always reset to page 0 when sorting
})
);
};
const onCategoriesDropdownChange = (
option: SingleValue<CustomOptionType>
) => {
router.push(
getPathWithQueryParams(pathname, {
category_id: option?.value !== "undefined" ? option?.value : undefined,
query: queryParams.query,
order_key: initialSortHeader,
order_direction: initialSortDirection,
page: 0, // Always reset to page 0 when searching
})
);
};
const renderHeaderFilters = () => (
<div className={`${baseClass}__header-filters`}>
<SearchField
placeholder="Search by name"
onChange={onSearchQueryChange}
defaultValue={queryParams.query}
/>
<DropdownWrapper
options={CATEGORIES_NAV_ITEMS.map((category) => ({
...category,
value: String(category.id),
}))}
value={String(queryParams.category_id || 0)}
onChange={onCategoriesDropdownChange}
name="categories-dropdown"
className={`${baseClass}__categories-dropdown`}
/>
</div>
);
const renderCategoriesMenu = () => (
<CategoriesMenu
queryParams={queryParams}
categories={CATEGORIES_NAV_ITEMS}
/>
);
if (isLoading) return <Spinner />;
if (isError) return <EmptyTable header="Error loading software." />;
// Empty state
if ((isEmpty || !selfServiceData) && !isFetching) {
return (
<EmptyTable
graphicName="empty-software"
header="No self-service software available yet"
info="Your organization didnt add any self-service software."
/>
);
}
return (
<Card
className={`${baseClass}__self-service-card`}
borderRadiusSize="xxlarge"
paddingSize="xlarge"
includeShadow
>
<CardHeader
header="Self-service"
subheader={
<>
Install organization-approved apps provided by your IT department.{" "}
{contactUrl && (
<span>
If you need help,{" "}
<CustomLink url={contactUrl} text="reach out to IT" newTab />
</span>
)}
</>
}
/>
{renderHeaderFilters()}
<div className={`${baseClass}__table`}>
{renderCategoriesMenu()}
<TableContainer
columnConfigs={tableConfig}
data={filterSoftwareByCategory(
enhancedSoftware || [],
queryParams.category_id
)}
isLoading={isFetching}
defaultSortHeader={initialSortHeader}
defaultSortDirection={initialSortDirection}
onQueryChange={onSortChange}
pageIndex={initialSortPage}
disableNextPage={selfServiceData?.meta.has_next_results === false}
pageSize={20}
searchQuery={queryParams.query}
searchQueryColumn="name"
isClientSideFilter
isClientSidePagination
disableAutoResetPage
onClientSidePaginationChange={onClientSidePaginationChange}
emptyComponent={() =>
isEmptySearch ? (
<EmptyTable
graphicName="empty-search-question"
header="No items match your search"
info={
<>
Not finding what you&apos;re looking for?{" "}
<CustomLink
url={contactUrl}
text="Reach out to IT"
newTab
/>
</>
}
/>
) : (
<EmptySoftwareTable />
)
}
showMarkAllPages={false}
isAllPagesSelected={false}
disableTableHeader
disableCount
/>
</div>
</Card>
);
};
export default SelfServiceCard;

View file

@ -18,7 +18,7 @@ import InstallStatusCell from "../InstallStatusCell/InstallStatusCell";
import { installStatusSortType } from "../helpers";
import HostInstallerActionCell from "../../HostSoftwareLibrary/HostInstallerActionCell/HostInstallerActionCell";
type ISoftwareTableConfig = Column<IHostSoftwareWithUiStatus>;
type ISelfServiceTableConfig = Column<IHostSoftwareWithUiStatus>;
type ITableHeaderProps = IHeaderProps<IHostSoftwareWithUiStatus>;
type ITableStringCellProps = IStringCellProps<IHostSoftwareWithUiStatus>;
type IStatusCellProps = CellProps<
@ -60,8 +60,8 @@ export const generateSoftwareTableHeaders = ({
onClickInstallAction,
onClickUninstallAction,
onClickOpenInstructionsAction,
}: ISelfServiceTableHeaders): ISoftwareTableConfig[] => {
const tableHeaders: ISoftwareTableConfig[] = [
}: ISelfServiceTableHeaders): ISelfServiceTableConfig[] => {
const tableHeaders: ISelfServiceTableConfig[] = [
{
Header: (cellProps: ITableHeaderProps) => (
<HeaderCell value="Name" isSortedDesc={cellProps.column.isSortedDesc} />