Fix issues with admin settings pages UI resetting dropdown menus on unrelated state changes (#16468)

This commit is contained in:
Sarah Gillespie 2024-02-07 09:59:31 -06:00 committed by GitHub
parent 6d1eee6279
commit 94af293ec6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 376 additions and 304 deletions

View File

@ -0,0 +1 @@
- Fixed UI issues where dropdown menus were not displaying correctly in the administrative settings page.

View File

@ -1,6 +1,5 @@
import React, { useState, useContext, useCallback } from "react";
import React, { useState, useContext, useCallback, useMemo } from "react";
import { useQuery } from "react-query";
import memoize from "memoize-one";
import { NotificationContext } from "context/notification";
import { IConfig } from "interfaces/config";
@ -12,22 +11,17 @@ import {
IIntegrations,
} from "interfaces/integration";
import { IApiError } from "interfaces/errors";
import { IEmptyTableProps } from "interfaces/empty_table";
import Button from "components/buttons/Button";
// @ts-ignore
import configAPI from "services/entities/config";
import TableContainer from "components/TableContainer";
import TableDataError from "components/DataError";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import SectionHeader from "components/SectionHeader";
import AddIntegrationModal from "./components/AddIntegrationModal";
import DeleteIntegrationModal from "./components/DeleteIntegrationModal";
import EditIntegrationModal from "./components/EditIntegrationModal";
import EmptyIntegrationsTable from "./components/EmptyIntegrationsTable";
import {
generateTableHeaders,
@ -91,9 +85,9 @@ const Integrations = (): JSX.Element => {
}
);
const combineJiraAndZendesk = memoize(() => {
return combineDataSets(jiraIntegrations || [], zendeskIntegrations || []);
});
// TODO: Cleanup useCallbacks, add missing dependencies, use state setter functions, e.g.,
// `setShowAddIntegrationModal((prevState) => !prevState)`, instead of including state
// variables as dependencies for toggles, etc.
const toggleAddIntegrationModal = useCallback(() => {
setShowAddIntegrationModal(!showAddIntegrationModal);
@ -349,53 +343,30 @@ const Integrations = (): JSX.Element => {
[integrationEditing, toggleEditIntegrationModal]
);
const onActionSelection = (
action: string,
integration: IIntegrationTableData
): void => {
switch (action) {
case "edit":
toggleEditIntegrationModal(integration);
break;
case "delete":
toggleDeleteIntegrationModal(integration);
break;
default:
}
};
const onActionSelection = useCallback(
(action: string, integration: IIntegrationTableData): void => {
switch (action) {
case "edit":
toggleEditIntegrationModal(integration);
break;
case "delete":
toggleDeleteIntegrationModal(integration);
break;
default:
// do nothing
}
},
[toggleEditIntegrationModal, toggleDeleteIntegrationModal]
);
const emptyState = () => {
const emptyIntegrations: IEmptyTableProps = {
graphicName: "empty-integrations",
header: "Set up integrations",
info:
"Create tickets automatically when Fleet detects new software vulnerabilities or hosts failing policies.",
additionalInfo: (
<>
Want to learn more?&nbsp;
<CustomLink
url="https://fleetdm.com/docs/using-fleet/automations"
text="Read about automations"
newTab
/>
</>
),
primaryButton: (
<Button
variant="brand"
className={`${noIntegrationsClass}__add-button`}
onClick={toggleAddIntegrationModal}
>
Add integration
</Button>
),
};
return emptyIntegrations;
};
const tableHeaders = useMemo(() => generateTableHeaders(onActionSelection), [
onActionSelection,
]);
const tableHeaders = generateTableHeaders(onActionSelection);
const tableData = combineJiraAndZendesk();
const tableData = useMemo(
() => combineDataSets(jiraIntegrations || [], zendeskIntegrations || []),
[jiraIntegrations, zendeskIntegrations]
);
return (
<div className={`${baseClass}`}>
@ -421,15 +392,12 @@ const Integrations = (): JSX.Element => {
hideButton: !tableData?.length,
}}
resultsTitle={"integrations"}
emptyComponent={() =>
EmptyTable({
graphicName: emptyState().graphicName,
header: emptyState().header,
info: emptyState().info,
additionalInfo: emptyState().additionalInfo,
primaryButton: emptyState().primaryButton,
})
}
emptyComponent={() => (
<EmptyIntegrationsTable
className={noIntegrationsClass}
onActionButtonClick={toggleAddIntegrationModal}
/>
)}
showMarkAllPages={false}
isAllPagesSelected={false}
disablePagination

View File

@ -0,0 +1,42 @@
import React from "react";
import Button from "components/buttons/Button";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
const EmptyIntegrationsTable = ({
className,
onActionButtonClick,
}: {
className: string;
onActionButtonClick: () => void;
}) => {
return (
<EmptyTable
graphicName="empty-integrations"
header="Set up integrations"
info="Create tickets automatically when Fleet detects new software vulnerabilities or hosts failing policies."
additionalInfo={
<>
Want to learn more?&nbsp;
<CustomLink
url="https://fleetdm.com/docs/using-fleet/automations"
text="Read about automations"
newTab
/>
</>
}
primaryButton={
<Button
variant="brand"
className={`${className}__add-button`}
onClick={onActionButtonClick}
>
Add integration
</Button>
}
/>
);
};
export default EmptyIntegrationsTable;

View File

@ -5,7 +5,6 @@ import { Link } from "react-router";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import useTeamIdParam from "hooks/useTeamIdParam";
import { IEmptyTableProps } from "interfaces/empty_table";
import { IApiError } from "interfaces/errors";
import { INewTeamUsersBody, ITeam } from "interfaces/team";
import { IUpdateUserFormData, IUser, IUserFormErrors } from "interfaces/user";
@ -16,12 +15,9 @@ import inviteAPI from "services/entities/invites";
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
import { DEFAULT_CREATE_USER_ERRORS } from "utilities/constants";
import Button from "components/buttons/Button";
import TableContainer from "components/TableContainer";
import TableDataError from "components/DataError";
import EmptyTable from "components/EmptyTable";
import Spinner from "components/Spinner";
import CustomLink from "components/CustomLink";
import CreateUserModal from "pages/admin/UserManagementPage/components/CreateUserModal";
import EditUserModal from "../../../UserManagementPage/components/EditUserModal";
import {
@ -29,6 +25,7 @@ import {
NewUserType,
} from "../../../UserManagementPage/components/UserForm/UserForm";
import userManagementHelpers from "../../../UserManagementPage/helpers";
import EmptyMembersTable from "./components/EmptyUsersTable";
import AddUsersModal from "./components/AddUsersModal/AddUsersModal";
import RemoveUserModal from "./components/RemoveUserModal/RemoveUserModal";
@ -354,63 +351,30 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => {
]
);
const onActionSelection = (action: string, user: IUser): void => {
switch (action) {
case "edit":
toggleEditUserModal(user);
break;
case "remove":
toggleRemoveUserModal(user);
break;
default:
}
};
const onActionSelection = useCallback(
(action: string, user: IUser): void => {
switch (action) {
case "edit":
toggleEditUserModal(user);
break;
case "remove":
toggleRemoveUserModal(user);
break;
default:
}
},
[toggleEditUserModal, toggleRemoveUserModal]
);
const emptyState = () => {
const emptyUsers: IEmptyTableProps = {
graphicName: "empty-users",
header: "No users on this team",
info: (
<>
<CustomLink url={PATHS.ADMIN_USERS} text="Global users" /> can still
access this team.
</>
),
};
if (searchString !== "") {
delete emptyUsers.graphicName;
emptyUsers.header = "We couldnt find any users.";
emptyUsers.info =
"Expecting to see users? Try again in a few seconds as the system catches up.";
} else if (isGlobalAdmin) {
emptyUsers.primaryButton = (
<Button
variant="brand"
className={`${noUsersClass}__create-button`}
onClick={toggleAddUserModal}
>
Add users
</Button>
);
} else if (isTeamAdmin) {
emptyUsers.primaryButton = (
<Button
variant="brand"
className={`${noUsersClass}__create-button`}
onClick={toggleCreateUserModal}
>
Create user
</Button>
);
}
return emptyUsers;
};
const columnConfigs = useMemo(
() => generateColumnConfigs(onActionSelection),
[onActionSelection]
);
if (!isRouteOk) {
return <Spinner />;
}
const columnConfigs = generateColumnConfigs(onActionSelection);
const userIds = teamUsers ? teamUsers.map((user) => user.id) : [];
return (
@ -446,14 +410,16 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => {
}}
onQueryChange={({ searchQuery }) => setSearchString(searchQuery)}
inputPlaceHolder={"Search"}
emptyComponent={() =>
EmptyTable({
graphicName: emptyState().graphicName,
header: emptyState().header,
info: emptyState().info,
primaryButton: emptyState().primaryButton,
})
}
emptyComponent={() => (
<EmptyMembersTable
className={noUsersClass}
isGlobalAdmin={!!isGlobalAdmin}
isTeamAdmin={!!isTeamAdmin}
searchString={searchString}
toggleAddUserModal={toggleAddUserModal}
toggleCreateMemberModal={toggleCreateUserModal}
/>
)}
showMarkAllPages={false}
isAllPagesSelected={false}
searchable={userIds.length > 0 || searchString !== ""}

View File

@ -0,0 +1,93 @@
import React from "react";
import Button from "components/buttons/Button";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import PATHS from "router/paths";
interface IEmptyUsersTableProps {
className: string;
searchString: string;
isGlobalAdmin: boolean;
isTeamAdmin: boolean;
toggleAddUserModal: () => void;
toggleCreateMemberModal: () => void;
}
const infoLink = (
<>
<CustomLink url={PATHS.ADMIN_USERS} text="Global users" /> can still access
this team.
</>
);
const CreateUserButton = ({
className,
isGlobalAdmin,
isTeamAdmin,
toggleAddUserModal,
toggleCreateMemberModal,
}: Omit<IEmptyUsersTableProps, "searchString">) => {
if (!isGlobalAdmin && !isTeamAdmin) {
return null;
}
if (isGlobalAdmin) {
return (
<Button
variant="brand"
className={`${className}__create-button`}
onClick={toggleAddUserModal}
>
Add user
</Button>
);
}
return (
<Button
variant="brand"
className={`${className}__create-button`}
onClick={toggleCreateMemberModal}
>
Create user
</Button>
);
};
const EmptyMembersTable = ({
className,
isGlobalAdmin,
isTeamAdmin,
searchString,
toggleAddUserModal,
toggleCreateMemberModal,
}: IEmptyUsersTableProps) => {
if (searchString !== "") {
return (
<EmptyTable
header="We couldnt find any users."
info="Expecting to see users? Try again in a few seconds as the system catches up."
/>
);
}
return (
<EmptyTable
graphicName="empty-users"
header="No users on this team"
info={infoLink}
primaryButton={
<CreateUserButton
className={className}
isGlobalAdmin={isGlobalAdmin}
isTeamAdmin={isTeamAdmin}
toggleAddUserModal={toggleAddUserModal}
toggleCreateMemberModal={toggleCreateMemberModal}
/>
}
/>
);
};
export default EmptyMembersTable;

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useContext } from "react";
import React, { useState, useCallback, useContext, useMemo } from "react";
import { useQuery } from "react-query";
import { useErrorHandler } from "react-error-boundary";
@ -6,24 +6,22 @@ import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import { ITeam } from "interfaces/team";
import { IApiError } from "interfaces/errors";
import { IEmptyTableProps } from "interfaces/empty_table";
import usersAPI, { IGetMeResponse } from "services/entities/users";
import teamsAPI, {
ILoadTeamsResponse,
ITeamFormData,
} from "services/entities/teams";
import Button from "components/buttons/Button";
import TableContainer from "components/TableContainer";
import TableDataError from "components/DataError";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import SandboxGate from "components/Sandbox/SandboxGate";
import SandboxMessage from "components/Sandbox/SandboxMessage";
import CreateTeamModal from "./components/CreateTeamModal";
import DeleteTeamModal from "./components/DeleteTeamModal";
import EditTeamModal from "./components/EditTeamModal";
import EmptyTeamsTable from "./components/EmptyTeamsTable";
import { generateTableHeaders, generateDataSet } from "./TeamTableConfig";
const baseClass = "team-management";
@ -42,7 +40,6 @@ const TeamManagementPage = (): JSX.Element => {
const [showDeleteTeamModal, setShowDeleteTeamModal] = useState(false);
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
const [teamEditing, setTeamEditing] = useState<ITeam>();
const [searchString, setSearchString] = useState("");
const [backendValidators, setBackendValidators] = useState<{
[key: string]: string;
}>({});
@ -70,6 +67,10 @@ const TeamManagementPage = (): JSX.Element => {
}
);
// TODO: Cleanup useCallbacks, add missing dependencies, use state setter functions, e.g.,
// `setShowCreateTeamModal((prevState) => !prevState)`, instead of including state
// variables as dependencies for toggles, etc.
const toggleCreateTeamModal = useCallback(() => {
setShowCreateTeamModal(!showCreateTeamModal);
setBackendValidators({});
@ -97,21 +98,6 @@ const TeamManagementPage = (): JSX.Element => {
]
);
const onQueryChange = useCallback(
(queryData) => {
if (teams) {
setSearchString(queryData.searchQuery);
const { pageIndex, pageSize, searchQuery } = queryData;
teamsAPI.loadAll({
page: pageIndex,
perPage: pageSize,
globalFilter: searchQuery,
});
}
},
[setSearchString]
);
const onCreateSubmit = useCallback(
(formData: ITeamFormData) => {
setIsUpdatingTeams(true);
@ -138,7 +124,7 @@ const TeamManagementPage = (): JSX.Element => {
setIsUpdatingTeams(false);
});
},
[toggleCreateTeamModal]
[toggleCreateTeamModal, refetchMe, refetchTeams, renderFlash]
);
const onDeleteSubmit = useCallback(() => {
@ -165,7 +151,15 @@ const TeamManagementPage = (): JSX.Element => {
toggleDeleteTeamModal();
});
}
}, [teamEditing, toggleDeleteTeamModal]);
}, [
currentTeam,
teamEditing,
refetchMe,
refetchTeams,
renderFlash,
setCurrentTeam,
toggleDeleteTeamModal,
]);
const onEditSubmit = useCallback(
(formData: ITeamFormData) => {
@ -202,54 +196,30 @@ const TeamManagementPage = (): JSX.Element => {
});
}
},
[teamEditing, toggleEditTeamModal]
[teamEditing, toggleEditTeamModal, refetchTeams, renderFlash]
);
const onActionSelection = (action: string, team: ITeam): void => {
switch (action) {
case "edit":
toggleEditTeamModal(team);
break;
case "delete":
toggleDeleteTeamModal(team);
break;
default:
}
};
const onActionSelection = useCallback(
(action: string, team: ITeam): void => {
switch (action) {
case "edit":
toggleEditTeamModal(team);
break;
case "delete":
toggleDeleteTeamModal(team);
break;
default:
}
},
[toggleEditTeamModal, toggleDeleteTeamModal]
);
const emptyState = () => {
const emptyTeams: IEmptyTableProps = {
graphicName: "empty-teams",
header: "Set up team permissions",
info:
"Keep your organization organized and efficient by ensuring every user has the correct access to the right hosts.",
additionalInfo: (
<>
{" "}
Want to learn more?&nbsp;
<CustomLink
url="https://fleetdm.com/docs/using-fleet/teams"
text="Read about teams"
newTab
/>
</>
),
primaryButton: (
<Button
variant="brand"
className={`${noTeamsClass}__create-button`}
onClick={toggleCreateTeamModal}
>
Create team
</Button>
),
};
return emptyTeams;
};
const tableHeaders = generateTableHeaders(onActionSelection);
const tableData = teams ? generateDataSet(teams) : [];
const tableHeaders = useMemo(() => generateTableHeaders(onActionSelection), [
onActionSelection,
]);
const tableData = useMemo(() => (teams ? generateDataSet(teams) : []), [
teams,
]);
return (
<div className={`${baseClass}`}>
@ -275,28 +245,22 @@ const TeamManagementPage = (): JSX.Element => {
isLoading={isFetchingTeams}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
inputPlaceHolder={"Search"}
actionButton={{
name: "create team",
buttonText: "Create team",
variant: "brand",
onActionButtonClick: toggleCreateTeamModal,
hideButton: teams && teams.length === 0 && searchString === "",
hideButton: teams && teams.length === 0,
}}
onQueryChange={onQueryChange}
resultsTitle={"teams"}
emptyComponent={() =>
EmptyTable({
graphicName: "empty-teams",
header: emptyState().header,
info: emptyState().info,
additionalInfo: emptyState().additionalInfo,
primaryButton: emptyState().primaryButton,
})
}
emptyComponent={() => (
<EmptyTeamsTable
className={noTeamsClass}
onActionButtonClick={toggleCreateTeamModal}
/>
)}
showMarkAllPages={false}
isAllPagesSelected={false}
searchable={teams && teams.length > 0 && searchString !== ""}
isClientSidePagination
/>
)}

View File

@ -0,0 +1,43 @@
import React from "react";
import Button from "components/buttons/Button";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
const EmptyTeamsTable = ({
className,
onActionButtonClick,
}: {
className: string;
onActionButtonClick: () => void;
}) => {
return (
<EmptyTable
graphicName="empty-teams"
header="Set up team permissions"
info="Keep your organization organized and efficient by ensuring every user has the correct access to the right hosts."
additionalInfo={
<>
{" "}
Want to learn more?&nbsp;
<CustomLink
url="https://fleetdm.com/docs/using-fleet/teams"
text="Read about teams"
newTab
/>
</>
}
primaryButton={
<Button
variant="brand"
className={`${className}__create-button`}
onClick={onActionButtonClick}
>
Create team
</Button>
}
/>
);
};
export default EmptyTeamsTable;

View File

@ -28,6 +28,13 @@ import { NewUserType } from "../UserForm/UserForm";
import CreateUserModal from "../CreateUserModal";
import EditUserModal from "../EditUserModal";
const EmptyUsersTable = () => (
<EmptyTable
header="No users match the current criteria."
info="Expecting to see users? Try again in a few seconds as the system catches up."
/>
);
interface IUsersTableProps {
router: InjectedRouter; // v3
}
@ -94,6 +101,10 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
}
);
// TODO: Cleanup useCallbacks, add missing dependencies, use state setter functions, e.g.,
// `setShowCreateUserModal((prevState) => !prevState)`, instead of including state
// variables as dependencies for toggles, etc.
// TOGGLE MODALS
const toggleCreateUserModal = useCallback(() => {
@ -140,40 +151,54 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
// FUNCTIONS
const combineUsersAndInvites = useCallback(
(usersData, invitesData, currentUserId) => {
return combineDataSets(usersData, invitesData, currentUserId);
},
[]
);
const goToUserSettingsPage = () => {
const goToUserSettingsPage = useCallback(() => {
const { USER_SETTINGS } = paths;
router.push(USER_SETTINGS);
};
}, [router]);
const onActionSelect = (value: string, user: IUser | IInvite) => {
switch (value) {
case "edit":
toggleEditUserModal(user);
break;
case "delete":
toggleDeleteUserModal(user);
break;
case "passwordReset":
toggleResetPasswordUserModal(user);
break;
case "resetSessions":
toggleResetSessionsUserModal(user);
break;
case "editMyAccount":
goToUserSettingsPage();
break;
default:
return null;
}
return null;
};
const onActionSelect = useCallback(
(value: string, user: IUser | IInvite) => {
switch (value) {
case "edit":
toggleEditUserModal(user);
break;
case "delete":
toggleDeleteUserModal(user);
break;
case "passwordReset":
toggleResetPasswordUserModal(user);
break;
case "resetSessions":
toggleResetSessionsUserModal(user);
break;
case "editMyAccount":
goToUserSettingsPage();
break;
default:
return null;
}
return null;
},
[
toggleEditUserModal,
toggleDeleteUserModal,
toggleResetPasswordUserModal,
toggleResetSessionsUserModal,
goToUserSettingsPage,
]
);
const onTableQueryChange = useCallback(
(queryData: ITableQueryData) => {
const { searchQuery } = queryData;
setQuerySearchText(searchQuery);
refetchUsers();
refetchInvites();
},
[refetchUsers, refetchInvites]
);
const getUser = (type: string, id: number) => {
let userData;
@ -501,9 +526,9 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
);
};
const tableHeaders = generateTableHeaders(
onActionSelect,
isPremiumTier || false
const tableHeaders = useMemo(
() => generateTableHeaders(onActionSelect, isPremiumTier || false),
[onActionSelect, isPremiumTier]
);
const loadingTableData =
@ -513,72 +538,42 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
const tableData = useMemo(
() =>
!loadingTableData && !tableDataError
? combineUsersAndInvites(users, invites, currentUser?.id)
!loadingTableData &&
!tableDataError &&
users &&
invites &&
currentUser?.id
? combineDataSets(users, invites, currentUser.id)
: [],
[
loadingTableData,
tableDataError,
users,
invites,
currentUser?.id,
combineUsersAndInvites,
]
[loadingTableData, tableDataError, users, invites, currentUser?.id]
);
const renderTable = useCallback(() => {
// NOTE: this is called once on the initial rendering. The initial render of
// the TableContainer child component calls this handler.
const onTableQueryChange = (queryData: ITableQueryData) => {
const { searchQuery } = queryData;
setQuerySearchText(searchQuery);
refetchUsers();
refetchInvites();
};
const emptyState = {
header: "No users match the current criteria.",
info:
"Expecting to see users? Try again in a few seconds as the system catches up.",
};
return (
<TableContainer
columnConfigs={tableHeaders}
data={tableData}
isLoading={loadingTableData}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
inputPlaceHolder={"Search by name or email"}
actionButton={{
name: "create user",
buttonText: "Create user",
onActionButtonClick: toggleCreateUserModal,
}}
onQueryChange={onTableQueryChange}
resultsTitle={"users"}
emptyComponent={() => EmptyTable(emptyState)}
searchable
showMarkAllPages={false}
isAllPagesSelected={false}
isClientSidePagination
/>
);
}, [
tableHeaders,
tableData,
loadingTableData,
toggleCreateUserModal,
refetchUsers,
refetchInvites,
]);
return (
<>
{/* TODO: find a way to move these controls into the table component */}
{tableDataError ? <TableDataError /> : renderTable()}
{tableDataError ? (
<TableDataError />
) : (
<TableContainer
columnConfigs={tableHeaders}
data={tableData}
isLoading={loadingTableData}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
inputPlaceHolder={"Search by name or email"}
actionButton={{
name: "create user",
buttonText: "Create user",
onActionButtonClick: toggleCreateUserModal,
}}
onQueryChange={onTableQueryChange}
resultsTitle={"users"}
emptyComponent={EmptyUsersTable}
searchable
showMarkAllPages={false}
isAllPagesSelected={false}
isClientSidePagination
/>
)}
{showCreateUserModal && renderCreateUserModal()}
{showEditUserModal && renderEditUserModal()}
{showDeleteUserModal && renderDeleteUserModal()}