From ec23d4ee8eb15632e5c629ffc1cc1cd795429be4 Mon Sep 17 00:00:00 2001
From: Martavis Parker <47053705+martavis@users.noreply.github.com>
Date: Sat, 10 Jul 2021 10:29:27 -0700
Subject: [PATCH] Select all matching hosts (#1226)
* #940 passing matching props to table; typescript
* #940 fixed use of results title
* #940 logic to swap states of selected and not
* #940 optimized rendering selected text
* #940 began styling to match flexbox spacing
* #940 added logic to API
* #940 clean up
* #940 fixed action
* #940 fixed conflict and coment
* #940 hide select matching if under 100 hosts
* #940 fixed issue with status filter
---
.../DataTable/DataTable.tests.tsx | 22 +++
.../{DataTable.jsx => DataTable.tsx} | 119 ++++++++++++----
.../TableContainer/DataTable/_styles.scss | 8 ++
.../TableContainer/TableContainer.tsx | 61 +++++----
.../components/buttons/Button/_styles.scss | 4 +
frontend/fleet/endpoints.ts | 1 +
frontend/fleet/entities/hosts.ts | 20 +++
.../MembersPagePage/MembersPage.tsx | 2 +
.../TeamManagementPage/TeamManagementPage.tsx | 2 +
.../UserManagementPage/UserManagementPage.jsx | 2 +-
.../hosts/ManageHostsPage/ManageHostsPage.jsx | 68 ++++++++-
.../redux/nodes/entities/hosts/actions.ts | 22 +++
frontend/typings/react-table-config.d.ts | 129 ++++++++++++++++++
frontend/utilities/hooks/useDeepEffect.ts | 31 +++++
14 files changed, 434 insertions(+), 57 deletions(-)
rename frontend/components/TableContainer/DataTable/{DataTable.jsx => DataTable.tsx} (58%)
create mode 100644 frontend/typings/react-table-config.d.ts
create mode 100644 frontend/utilities/hooks/useDeepEffect.ts
diff --git a/frontend/components/TableContainer/DataTable/DataTable.tests.tsx b/frontend/components/TableContainer/DataTable/DataTable.tests.tsx
index 3a925721a8..f4a916d68c 100644
--- a/frontend/components/TableContainer/DataTable/DataTable.tests.tsx
+++ b/frontend/components/TableContainer/DataTable/DataTable.tests.tsx
@@ -5,6 +5,8 @@ import { noop } from "lodash";
// @ts-ignore
import DataTable from "./DataTable";
+const DEFAULT_PAGE_SIZE = 100;
+
describe("DataTable - component", () => {
it("renders a data table based on the columns and data passed in", () => {
const columns = [
@@ -24,6 +26,10 @@ describe("DataTable - component", () => {
data={data}
isLoading={false}
onSort={noop}
+ showMarkAllPages={false}
+ isAllPagesSelected={false}
+ resultsTitle="users"
+ defaultPageSize={DEFAULT_PAGE_SIZE}
/>
);
@@ -61,6 +67,10 @@ describe("DataTable - component", () => {
sortDirection={"desc"}
isLoading={false}
onSort={noop}
+ showMarkAllPages={false}
+ isAllPagesSelected={false}
+ resultsTitle="users"
+ defaultPageSize={DEFAULT_PAGE_SIZE}
/>
);
@@ -82,6 +92,10 @@ describe("DataTable - component", () => {
sortDirection={"desc"}
isLoading={false}
onSort={noop}
+ showMarkAllPages={false}
+ isAllPagesSelected={false}
+ resultsTitle="users"
+ defaultPageSize={DEFAULT_PAGE_SIZE}
/>
);
@@ -111,6 +125,10 @@ describe("DataTable - component", () => {
sortDirection={"desc"}
isLoading={false}
onSort={noop}
+ showMarkAllPages={false}
+ isAllPagesSelected={false}
+ resultsTitle="users"
+ defaultPageSize={DEFAULT_PAGE_SIZE}
/>
);
@@ -129,6 +147,10 @@ describe("DataTable - component", () => {
sortDirection={"asc"}
isLoading={false}
onSort={noop}
+ showMarkAllPages={false}
+ isAllPagesSelected={false}
+ resultsTitle="users"
+ defaultPageSize={DEFAULT_PAGE_SIZE}
/>
);
diff --git a/frontend/components/TableContainer/DataTable/DataTable.jsx b/frontend/components/TableContainer/DataTable/DataTable.tsx
similarity index 58%
rename from frontend/components/TableContainer/DataTable/DataTable.jsx
rename to frontend/components/TableContainer/DataTable/DataTable.tsx
index 8852904daa..0328840e40 100644
--- a/frontend/components/TableContainer/DataTable/DataTable.jsx
+++ b/frontend/components/TableContainer/DataTable/DataTable.tsx
@@ -1,25 +1,44 @@
import React, { useMemo, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import { useTable, useSortBy, useRowSelect } from "react-table";
+import useDeepEffect from "utilities/hooks/useDeepEffect";
import Spinner from "components/loaders/Spinner";
import Button from "../../buttons/Button";
const baseClass = "data-table-container";
+interface IDataTableProps {
+ columns: any;
+ data: any;
+ isLoading: boolean;
+ sortHeader: any;
+ sortDirection: any;
+ onSort: any; // TODO: an event type
+ onSelectActionClick: any; // TODO: an event type
+ showMarkAllPages: boolean;
+ isAllPagesSelected: boolean; // TODO: make dependent on showMarkAllPages
+ toggleAllPagesSelected?: any; // TODO: an event type and make it dependent on showMarkAllPages
+ resultsTitle: string;
+ defaultPageSize: number;
+}
+
// This data table uses react-table for implementation. The relevant documentation of the library
// can be found here https://react-table.tanstack.com/docs/api/useTable
-const DataTable = (props) => {
- const {
- columns: tableColumns,
- data: tableData,
- isLoading,
- sortHeader,
- sortDirection,
- onSort,
- onSelectActionClick,
- } = props;
-
+const DataTable = ({
+ columns: tableColumns,
+ data: tableData,
+ isLoading,
+ sortHeader,
+ sortDirection,
+ onSort,
+ onSelectActionClick,
+ showMarkAllPages,
+ isAllPagesSelected,
+ toggleAllPagesSelected,
+ resultsTitle,
+ defaultPageSize,
+}: IDataTableProps) => {
const columns = useMemo(() => {
return tableColumns;
}, [tableColumns]);
@@ -35,6 +54,7 @@ const DataTable = (props) => {
prepareRow,
selectedFlatRows,
toggleAllRowsSelected,
+ isAllRowsSelected,
state: tableState,
} = useTable(
{
@@ -69,15 +89,55 @@ const DataTable = (props) => {
}
}, [sortBy, sortHeader, onSort, sortDirection]);
+ useEffect(() => {
+ if (isAllPagesSelected) {
+ toggleAllRowsSelected(true);
+ }
+ }, [isAllPagesSelected]);
+
+ useDeepEffect(() => {
+ if (
+ Object.keys(selectedRowIds).length < rows.length &&
+ toggleAllPagesSelected
+ ) {
+ toggleAllPagesSelected(false);
+ }
+ }, [tableState.selectedRowIds, toggleAllPagesSelected]);
+
const onSelectActionButtonClick = useCallback(() => {
- const entityIds = selectedFlatRows.map((row) => row.original.id);
+ const entityIds = selectedFlatRows.map((row: any) => row.original.id);
onSelectActionClick(entityIds);
}, [onSelectActionClick, selectedFlatRows]);
+ const onToggleAllPagesClick = useCallback(() => {
+ toggleAllPagesSelected();
+ }, [toggleAllPagesSelected]);
+
const onClearSelectionClick = useCallback(() => {
toggleAllRowsSelected(false);
+ toggleAllPagesSelected(false);
}, [toggleAllRowsSelected]);
+ const renderSelectedText = (): JSX.Element => {
+ if (isAllPagesSelected) {
+ return
All matching {resultsTitle} are selected
;
+ }
+
+ if (isAllRowsSelected) {
+ return All {resultsTitle} on this page are selected
;
+ }
+
+ return (
+
+ {selectedFlatRows.length} selected
+
+ );
+ };
+
+ const shouldRenderToggleAllPages =
+ Object.keys(selectedRowIds).length >= defaultPageSize &&
+ showMarkAllPages &&
+ !isAllPagesSelected;
return (
@@ -99,18 +159,29 @@ const DataTable = (props) => {
-
- {selectedFlatRows.length} selected
-
-
-
+
+ {renderSelectedText()}
+ {shouldRenderToggleAllPages && (
+
+ )}
+
+
+
+
+
|
diff --git a/frontend/components/TableContainer/DataTable/_styles.scss b/frontend/components/TableContainer/DataTable/_styles.scss
index a1549b0297..8b75724e70 100644
--- a/frontend/components/TableContainer/DataTable/_styles.scss
+++ b/frontend/components/TableContainer/DataTable/_styles.scss
@@ -56,6 +56,7 @@
position: absolute;
top: -1px;
width: 100%;
+ max-width: calc( 100vw - 2rem - 2rem - 0.125rem - 300px); // same as wrapper page for flex layout
border: 0;
&__container {
@@ -64,6 +65,7 @@
&__inner {
display: flex;
+ justify-content: space-between;
align-items: center;
p {
@@ -79,6 +81,12 @@
margin-right: $pad-medium;
}
}
+
+ &__inner-left,
+ &__inner-right {
+ display: flex;
+ align-items: center;
+ }
}
tbody {
diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx
index 15d69a8760..105a82e8df 100644
--- a/frontend/components/TableContainer/TableContainer.tsx
+++ b/frontend/components/TableContainer/TableContainer.tsx
@@ -22,9 +22,9 @@ interface ITableQueryData {
pageIndex: number;
}
-interface ITableContainerProps
{
- columns: T[];
- data: U[];
+interface ITableContainerProps {
+ columns: any; // TODO: Figure out type
+ data: any; // TODO: Figure out type
isLoading: boolean;
defaultSortHeader: string;
defaultSortDirection: string;
@@ -36,10 +36,13 @@ interface ITableContainerProps {
onSelectActionClick?: (selectedItemIds: number[]) => void;
inputPlaceHolder: string;
disableActionButton?: boolean;
- resultsTitle?: string;
+ resultsTitle: string;
additionalQueries?: string;
emptyComponent: React.ElementType;
className?: string;
+ showMarkAllPages: boolean;
+ isAllPagesSelected: boolean; // TODO: make dependent on showMarkAllPages
+ toggleAllPagesSelected?: any; // TODO: an event type and make it dependent on showMarkAllPages
}
const baseClass = "table-container";
@@ -48,29 +51,28 @@ const DEFAULT_PAGE_SIZE = 100;
const DEFAULT_PAGE_INDEX = 0;
const DEBOUNCE_QUERY_DELAY = 300;
-const TableContainer = (
- props: ITableContainerProps
-): JSX.Element => {
- const {
- columns,
- data,
- isLoading,
- defaultSortHeader,
- defaultSortDirection,
- onActionButtonClick,
- inputPlaceHolder,
- additionalQueries,
- onQueryChange,
- resultsTitle,
- emptyComponent,
- className,
- disableActionButton,
- actionButtonText,
- actionButtonIcon,
- actionButtonVariant,
- onSelectActionClick,
- } = props;
-
+const TableContainer = ({
+ columns,
+ data,
+ isLoading,
+ defaultSortHeader,
+ defaultSortDirection,
+ onActionButtonClick,
+ inputPlaceHolder,
+ additionalQueries,
+ onQueryChange,
+ resultsTitle,
+ emptyComponent,
+ className,
+ disableActionButton,
+ actionButtonText,
+ actionButtonIcon,
+ actionButtonVariant,
+ onSelectActionClick,
+ showMarkAllPages,
+ isAllPagesSelected,
+ toggleAllPagesSelected,
+}: ITableContainerProps): JSX.Element => {
const [searchQuery, setSearchQuery] = useState("");
const [sortHeader, setSortHeader] = useState(defaultSortHeader || "");
const [sortDirection, setSortDirection] = useState(
@@ -215,6 +217,11 @@ const TableContainer = (
sortDirection={sortDirection}
onSort={onSortChange}
onSelectActionClick={onSelectActionClick}
+ showMarkAllPages={showMarkAllPages}
+ isAllPagesSelected={isAllPagesSelected}
+ toggleAllPagesSelected={toggleAllPagesSelected}
+ resultsTitle={resultsTitle}
+ defaultPageSize={DEFAULT_PAGE_SIZE}
/>
{
diff --git a/frontend/fleet/entities/hosts.ts b/frontend/fleet/entities/hosts.ts
index cb0d0bc0ee..8c16653964 100644
--- a/frontend/fleet/entities/hosts.ts
+++ b/frontend/fleet/entities/hosts.ts
@@ -92,5 +92,25 @@ export default (client: any) => {
})
);
},
+ transferToTeamByFilter: (
+ teamId: number | null,
+ query: string,
+ status: string,
+ labelId: number | null
+ ) => {
+ const { HOSTS_TRANSFER_BY_FILTER } = endpoints;
+ const endpoint = client._endpoint(HOSTS_TRANSFER_BY_FILTER);
+ return client.authenticatedPost(
+ endpoint,
+ JSON.stringify({
+ team_id: teamId,
+ filters: {
+ query,
+ status,
+ label_id: labelId,
+ },
+ })
+ );
+ },
};
};
diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx
index b7e16ab64e..dec140148b 100644
--- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx
+++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx
@@ -248,6 +248,8 @@ const MembersPage = (props: IMembersPageProps): JSX.Element => {
onQueryChange={onQueryChange}
inputPlaceHolder={"Search"}
emptyComponent={EmptyMembers}
+ showMarkAllPages={false}
+ isAllPagesSelected={false}
/>
{showAddMemberModal ? (
{
onQueryChange={onQueryChange}
resultsTitle={"teams"}
emptyComponent={EmptyTeams}
+ showMarkAllPages={false}
+ isAllPagesSelected={false}
/>
{showCreateTeamModal ? (
{renderCreateUserModal()}
diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx
index caa5bae210..835ef57320 100644
--- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx
+++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx
@@ -100,6 +100,8 @@ export class ManageHostsPage extends PureComponent {
? storedHiddenColumns
: defaultHiddenColumns,
selectedHostIds: [],
+ isAllMatchingHostsSelected: false,
+ searchQuery: "",
};
}
@@ -191,6 +193,10 @@ export class ManageHostsPage extends PureComponent {
if (sortHeader !== "") {
sortBy = [{ id: sortHeader, direction: sortDirection }];
}
+
+ // keep track as a local state to be used later
+ this.setState({ searchQuery });
+
dispatch(
getHosts(pageIndex, pageSize, selectedFilter, searchQuery, sortBy)
);
@@ -268,11 +274,35 @@ export class ManageHostsPage extends PureComponent {
};
onTransferHostSubmit = (team) => {
- const { toggleTransferHostModal } = this;
- const { dispatch } = this.props;
- const { selectedHostIds } = this.state;
+ const { toggleTransferHostModal, isAcceptableStatus } = this;
+ const { dispatch, selectedFilter, selectedLabel } = this.props;
+ const {
+ selectedHostIds,
+ isAllMatchingHostsSelected,
+ searchQuery,
+ } = this.state;
const teamId = team.id === "no-team" ? null : team.id;
- dispatch(hostActions.transferToTeam(teamId, selectedHostIds))
+ let action = hostActions.transferToTeam(teamId, selectedHostIds);
+
+ if (isAllMatchingHostsSelected) {
+ let status = "";
+ let labelId = null;
+
+ if (isAcceptableStatus(selectedFilter)) {
+ status = selectedFilter;
+ } else {
+ labelId = selectedLabel.id;
+ }
+
+ action = hostActions.transferToTeamByFilter(
+ teamId,
+ searchQuery,
+ status,
+ labelId
+ );
+ }
+
+ dispatch(action)
.then(() => {
const successMessage =
teamId === null
@@ -286,8 +316,19 @@ export class ManageHostsPage extends PureComponent {
renderFlash("error", "Could not transfer hosts. Please try again.")
);
});
+
toggleTransferHostModal();
this.setState({ selectedHostIds: [] });
+ this.setState({ isAllMatchingHostsSelected: false });
+ };
+
+ isAcceptableStatus = (filter) => {
+ return (
+ filter === "new" ||
+ filter === "online" ||
+ filter === "offline" ||
+ filter === "mia"
+ );
};
clearHostUpdates() {
@@ -312,6 +353,19 @@ export class ManageHostsPage extends PureComponent {
this.setState({ showTransferHostModal: !showTransferHostModal });
};
+ toggleAllMatchingHosts = (shouldSelect = undefined) => {
+ // shouldSelect?: boolean
+ const { isAllMatchingHostsSelected } = this.state;
+
+ if (shouldSelect !== undefined) {
+ this.setState({ isAllMatchingHostsSelected: shouldSelect });
+ } else {
+ this.setState({
+ isAllMatchingHostsSelected: !isAllMatchingHostsSelected,
+ });
+ }
+ };
+
renderEditColumnsModal = () => {
const { config, currentUser } = this.props;
const { showEditColumnsModal, hiddenColumns } = this.state;
@@ -578,11 +632,12 @@ export class ManageHostsPage extends PureComponent {
hosts,
loadingHosts,
} = this.props;
- const { hiddenColumns } = this.state;
+ const { hiddenColumns, isAllMatchingHostsSelected } = this.state;
const {
onTableQueryChange,
onEditColumnsClick,
onTransferToTeamClick,
+ toggleAllMatchingHosts,
} = this;
// The data has not been fetched yet.
@@ -615,6 +670,9 @@ export class ManageHostsPage extends PureComponent {
onQueryChange={onTableQueryChange}
resultsTitle={"hosts"}
emptyComponent={EmptyHosts}
+ showMarkAllPages
+ isAllPagesSelected={isAllMatchingHostsSelected}
+ toggleAllPagesSelected={toggleAllMatchingHosts}
/>
);
};
diff --git a/frontend/redux/nodes/entities/hosts/actions.ts b/frontend/redux/nodes/entities/hosts/actions.ts
index f7839ea6c0..eb4426d8ac 100644
--- a/frontend/redux/nodes/entities/hosts/actions.ts
+++ b/frontend/redux/nodes/entities/hosts/actions.ts
@@ -41,6 +41,27 @@ const transferToTeam = (teamId: number | null, hostIds: number[]): any => {
};
};
+const transferToTeamByFilter = (
+ teamId: number | null,
+ query: string,
+ status: string,
+ labelId: number | null
+): any => {
+ return (dispatch: any) => {
+ dispatch(loadRequest());
+ return Fleet.hosts
+ .transferToTeamByFilter(teamId, query, status, labelId)
+ .then(() => {
+ dispatch(transferHostsSuccess());
+ })
+ .catch((res: IApiError) => {
+ const errorsObject = formatErrorResponse(res);
+ dispatch(transferHostsFailure(errorsObject));
+ throw errorsObject;
+ });
+ };
+};
+
export const LOAD_PAGINATED = "LOAD_PAGINATED";
export const loadPaginated = (): any => {
return (dispatch: any) => {
@@ -80,6 +101,7 @@ export const refetchHostStart = (host: IHost): any => {
export default {
...actions,
transferToTeam,
+ transferToTeamByFilter,
refetchHostSuccess,
refetchHostFailure,
refetchHostStart,
diff --git a/frontend/typings/react-table-config.d.ts b/frontend/typings/react-table-config.d.ts
new file mode 100644
index 0000000000..cb7d717323
--- /dev/null
+++ b/frontend/typings/react-table-config.d.ts
@@ -0,0 +1,129 @@
+import {
+ UseColumnOrderInstanceProps,
+ UseColumnOrderState,
+ UseExpandedHooks,
+ UseExpandedInstanceProps,
+ UseExpandedOptions,
+ UseExpandedRowProps,
+ UseExpandedState,
+ UseFiltersColumnOptions,
+ UseFiltersColumnProps,
+ UseFiltersInstanceProps,
+ UseFiltersOptions,
+ UseFiltersState,
+ UseGlobalFiltersColumnOptions,
+ UseGlobalFiltersInstanceProps,
+ UseGlobalFiltersOptions,
+ UseGlobalFiltersState,
+ UseGroupByCellProps,
+ UseGroupByColumnOptions,
+ UseGroupByColumnProps,
+ UseGroupByHooks,
+ UseGroupByInstanceProps,
+ UseGroupByOptions,
+ UseGroupByRowProps,
+ UseGroupByState,
+ UsePaginationInstanceProps,
+ UsePaginationOptions,
+ UsePaginationState,
+ UseResizeColumnsColumnOptions,
+ UseResizeColumnsColumnProps,
+ UseResizeColumnsOptions,
+ UseResizeColumnsState,
+ UseRowSelectHooks,
+ UseRowSelectInstanceProps,
+ UseRowSelectOptions,
+ UseRowSelectRowProps,
+ UseRowSelectState,
+ UseRowStateCellProps,
+ UseRowStateInstanceProps,
+ UseRowStateOptions,
+ UseRowStateRowProps,
+ UseRowStateState,
+ UseSortByColumnOptions,
+ UseSortByColumnProps,
+ UseSortByHooks,
+ UseSortByInstanceProps,
+ UseSortByOptions,
+ UseSortByState,
+} from "react-table";
+
+declare module "react-table" {
+ // take this file as-is, or comment out the sections that don't apply to your plugin configuration
+
+ export interface TableOptions<
+ D extends Record
+ > extends UseExpandedOptions,
+ UseFiltersOptions,
+ UseGlobalFiltersOptions,
+ UseGroupByOptions,
+ UsePaginationOptions,
+ UseResizeColumnsOptions,
+ UseRowSelectOptions,
+ UseRowStateOptions,
+ UseSortByOptions,
+ // note that having Record here allows you to add anything to the options, this matches the spirit of the
+ // underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
+ // feature set, this is a safe default.
+ Record {}
+
+ export interface Hooks<
+ D extends Record = Record
+ > extends UseExpandedHooks,
+ UseGroupByHooks,
+ UseRowSelectHooks,
+ UseSortByHooks {}
+
+ export interface TableInstance<
+ D extends Record = Record
+ > extends UseColumnOrderInstanceProps,
+ UseExpandedInstanceProps,
+ UseFiltersInstanceProps,
+ UseGlobalFiltersInstanceProps,
+ UseGroupByInstanceProps,
+ UsePaginationInstanceProps,
+ UseRowSelectInstanceProps,
+ UseRowStateInstanceProps,
+ UseSortByInstanceProps {}
+
+ export interface TableState<
+ D extends Record = Record
+ > extends UseColumnOrderState,
+ UseExpandedState,
+ UseFiltersState,
+ UseGlobalFiltersState,
+ UseGroupByState,
+ UsePaginationState,
+ UseResizeColumnsState,
+ UseRowSelectState,
+ UseRowStateState,
+ UseSortByState {}
+
+ export interface ColumnInterface<
+ D extends Record = Record
+ > extends UseFiltersColumnOptions,
+ UseGlobalFiltersColumnOptions,
+ UseGroupByColumnOptions,
+ UseResizeColumnsColumnOptions,
+ UseSortByColumnOptions {}
+
+ export interface ColumnInstance<
+ D extends Record = Record
+ > extends UseFiltersColumnProps,
+ UseGroupByColumnProps,
+ UseResizeColumnsColumnProps,
+ UseSortByColumnProps {}
+
+ export interface Cell<
+ D extends Record = Record,
+ V = any
+ > extends UseGroupByCellProps,
+ UseRowStateCellProps {}
+
+ export interface Row<
+ D extends Record = Record
+ > extends UseExpandedRowProps,
+ UseGroupByRowProps,
+ UseRowSelectRowProps,
+ UseRowStateRowProps {}
+}
diff --git a/frontend/utilities/hooks/useDeepEffect.ts b/frontend/utilities/hooks/useDeepEffect.ts
new file mode 100644
index 0000000000..2e28b388cd
--- /dev/null
+++ b/frontend/utilities/hooks/useDeepEffect.ts
@@ -0,0 +1,31 @@
+import { useEffect, useRef } from "react";
+import { isEqual } from "lodash";
+
+/**
+ *
+ * @param fn Anonymous function passing into the hook
+ * @param deps What dependencies to watch for changes
+ *
+ * Adapted from https://betterprogramming.pub/how-to-use-the-react-hook-usedeepeffect-815818c0ad9d,
+ * this hook does a deeper check for changes within objects and arrays
+ */
+
+const useDeepEffect = (fn: () => void, deps: Array) => {
+ const isFirst = useRef(true);
+ const prevDeps = useRef(deps);
+
+ useEffect(() => {
+ const isSame = prevDeps.current.every((obj, index) =>
+ isEqual(obj, deps[index])
+ );
+
+ if (isFirst.current || !isSame) {
+ fn();
+ }
+
+ isFirst.current = false;
+ prevDeps.current = deps;
+ }, [deps, fn]);
+};
+
+export default useDeepEffect;