mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
FE: Refactor Self Service card into its own file (#32738)
This commit is contained in:
parent
3d78aa8c33
commit
c5d734f276
8 changed files with 480 additions and 232 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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'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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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 didn’t 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 didn’t 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're looking for?{" "}
|
||||
<CustomLink
|
||||
url={contactUrl}
|
||||
text="Reach out to IT"
|
||||
newTab
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptySoftwareTable />
|
||||
)
|
||||
}
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
disableTableHeader
|
||||
disableCount
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelfServiceCard;
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue