9349 new controls page (#9431)

# Addresses #9349

# Implements
https://www.loom.com/share/bbf8d6f97fe74e65a0c9a394f1bda3f1
- New Controls page, only visible to Global|Team Admins|Maintainers
- Header for free users is 'Controls', for premium is a teams filter
dropdown that defaults to 'No teams,' which filters via updating the URL
query param "team_id"
    - Includes tabs macUpdates (default) and macSettings
- Cleaned up how site nav items are conditionally included/excluded
based on authorization – see
`frontend/components/top_nav/SiteTopNav/navItems.ts`
- Updated masthead styles: Removed icons from site nav links; updated
colors and spacing; Updated default user avatar TBD in separate PR
(waiting on guidance)

# Checklist for submitter
- [x] Changes file added for user-visible changes in `changes/` 
- [x] Updated testing suite inventory
- [x] Manual QA for all new/changed functionality

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
Jacob Shandling 2023-01-26 11:33:54 -08:00 committed by GitHub
parent 86c1916989
commit 8a5569cd1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 279 additions and 202 deletions

View File

@ -0,0 +1 @@
- Implemented the new Controls page and updated styling of the site-level navigation.

View File

@ -21,6 +21,8 @@ interface ITeamsDropdownHeaderProps {
buttons?: (ctx: ITeamsDropdownState) => JSX.Element | null;
onChange: (ctx: ITeamsDropdownState) => void;
description: (ctx: ITeamsDropdownState) => JSX.Element | string | null;
includeNoTeams?: boolean;
includeAll?: boolean;
}
const TeamsDropdownHeader = ({
@ -31,6 +33,8 @@ const TeamsDropdownHeader = ({
buttons,
description,
onChange,
includeNoTeams = false,
includeAll = true,
}: ITeamsDropdownHeaderProps): JSX.Element | null => {
const teamId = parseInt(location?.query?.team_id || "", 10) || 0;
@ -50,9 +54,7 @@ const TeamsDropdownHeader = ({
isAnyTeamAdmin,
isTeamAdmin,
isOnlyObserver,
setAvailableTeams,
setCurrentTeam,
setCurrentUser,
} = useContext(AppContext);
// The dropdownState is the context and local state made available to callback functions.
@ -121,6 +123,7 @@ const TeamsDropdownHeader = ({
onChange({ ...dropdownState, teamId: availableTeam?.id });
}
},
// TODO: add missing deps to this array if doens't cause bugs
[location, router]
);
@ -188,6 +191,8 @@ const TeamsDropdownHeader = ({
onChange={(newSelectedValue: number) =>
handleTeamSelect(newSelectedValue)
}
includeNoTeams={includeNoTeams}
includeAll={includeAll}
/>
)}
{isPremiumTier &&

View File

@ -8,7 +8,8 @@ import { AppContext } from "context/app";
const generateDropdownOptions = (
teams: ITeamSummary[] | undefined,
includeAll: boolean
includeAll: boolean,
includeNoTeams?: boolean
) => {
if (!teams) {
return [];
@ -27,13 +28,20 @@ const generateDropdownOptions = (
value: 0,
});
}
if (includeNoTeams) {
options.unshift({
disabled: false,
label: "No teams",
value: 0,
});
}
return options;
};
interface ITeamsDropdownProps {
currentUserTeams: ITeamSummary[];
selectedTeamId?: number;
includeAll?: boolean; // Include the "All Teams" option;
includeNoTeams?: boolean;
isDisabled?: boolean;
onChange: (newSelectedValue: number) => void;
onOpen?: () => void;
@ -46,6 +54,7 @@ const TeamsDropdown = ({
currentUserTeams,
selectedTeamId,
includeAll = true,
includeNoTeams = false,
isDisabled,
onChange,
onOpen,
@ -55,8 +64,12 @@ const TeamsDropdown = ({
const teamOptions = useMemo(
() =>
generateDropdownOptions(currentUserTeams, includeAll && isOnGlobalTeam),
[currentUserTeams, includeAll, isOnGlobalTeam]
generateDropdownOptions(
currentUserTeams,
includeAll && isOnGlobalTeam,
includeNoTeams
),
[currentUserTeams, includeAll, isOnGlobalTeam, includeNoTeams]
);
const selectedValue = teamOptions.find(

View File

@ -12,11 +12,6 @@ import UserMenu from "components/top_nav/UserMenu";
import OrgLogoIcon from "components/icons/OrgLogoIcon";
import navItems, { INavItem } from "./navItems";
import HostsIcon from "../../../../assets/images/icon-main-hosts@2x-16x16@2x.png";
import SoftwareIcon from "../../../../assets/images/icon-software-16x16@2x.png";
import QueriesIcon from "../../../../assets/images/icon-main-queries@2x-16x16@2x.png";
import PacksIcon from "../../../../assets/images/icon-main-packs@2x-16x16@2x.png";
import PoliciesIcon from "../../../../assets/images/icon-main-policies-16x16@2x.png";
interface ISiteTopNavProps {
onLogoutUser: () => void;
@ -52,9 +47,7 @@ const SiteTopNav = ({
[`${navItemBaseClass}--active`]: active,
});
const iconClasses = classnames([`${navItemBaseClass}__icon`]);
if (iconName === "logo") {
if (iconName && iconName === "logo") {
return (
<li className={navItemClasses} key={`nav-item-${name}`}>
<Link
@ -67,25 +60,6 @@ const SiteTopNav = ({
);
}
const iconImage = () => {
switch (iconName) {
case "hosts":
return HostsIcon;
case "software":
return SoftwareIcon;
case "queries":
return QueriesIcon;
case "packs":
return PacksIcon;
default:
return PoliciesIcon;
}
};
const icon = (
<img src={iconImage()} alt={`${iconName} icon`} className={iconClasses} />
);
return (
<li className={navItemClasses} key={`nav-item-${name}`}>
{withContext ? (
@ -93,7 +67,6 @@ const SiteTopNav = ({
className={`${navItemBaseClass}__link`}
to={navItem.location.pathname}
>
{icon}
<span
className={`${navItemBaseClass}__name`}
data-text={navItem.name}
@ -106,7 +79,6 @@ const SiteTopNav = ({
className={`${navItemBaseClass}__link`}
to={navItem.location.pathname}
>
{icon}
<span
className={`${navItemBaseClass}__name`}
data-text={navItem.name}
@ -130,7 +102,7 @@ const SiteTopNav = ({
const renderNavItems = () => {
return (
<div className="site-nav-container">
<div className="site-nav-content">
<ul className="site-nav-list">
{userNavItems.map((navItem) => {
return renderNavItem(navItem);

View File

@ -1,11 +1,15 @@
.site-nav-item {
flex-grow: 1;
position: relative;
transition: color 200ms ease-in-out;
cursor: pointer;
max-height: 50px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: $core-fleet-black;
background-color: $site-nav-on-hover;
}
&--multiple.site-nav-item--active {
@ -50,9 +54,6 @@
&__link {
color: $core-white;
text-align: center;
display: flex;
align-items: center;
padding: 14px 20px 17px;
text-decoration: none;
}
@ -66,11 +67,11 @@
&--active {
border-bottom: 3px solid $core-vibrant-blue;
background-color: $core-fleet-black;
background-color: $site-nav-on-hover;
height: 47px;
&:hover {
background-color: $core-fleet-black;
background-color: $site-nav-on-hover;
}
.site-nav-item__name {
@ -86,7 +87,7 @@
top: 1px;
}
.site-nav-container {
.site-nav-content {
flex-grow: 1;
display: flex;
justify-content: space-between;
@ -95,7 +96,10 @@
.site-nav-list {
list-style: none;
width: 671px;
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
}

View File

@ -3,14 +3,15 @@ import URL_PREFIX from "router/url_prefix";
import { IUser } from "interfaces/user";
export interface INavItem {
icon: string;
name: string;
iconName: string;
icon?: string;
iconName?: string;
location: {
regex: RegExp;
pathname: string;
};
withContext?: boolean;
exclude?: boolean;
}
export default (
@ -25,6 +26,12 @@ export default (
return [];
}
const isMaintainerOrAdmin =
isGlobalMaintainer ||
isAnyTeamMaintainer ||
isGlobalAdmin ||
isAnyTeamAdmin;
const logo = [
{
icon: "logo",
@ -37,11 +44,9 @@ export default (
},
];
const userNavItems = [
const navItems = [
{
icon: "hosts",
name: "Hosts",
iconName: "hosts",
location: {
regex: new RegExp(`^${URL_PREFIX}/hosts/`),
pathname: PATHS.MANAGE_HOSTS,
@ -49,9 +54,15 @@ export default (
withContext: true,
},
{
icon: "software",
name: "Controls",
location: {
regex: new RegExp(`^${URL_PREFIX}/controls/`),
pathname: PATHS.CONTROLS,
},
exclude: !isMaintainerOrAdmin,
},
{
name: "Software",
iconName: "software",
location: {
regex: new RegExp(`^${URL_PREFIX}/software/`),
pathname: PATHS.MANAGE_SOFTWARE,
@ -59,21 +70,22 @@ export default (
withContext: true,
},
{
icon: "query",
name: "Queries",
iconName: "queries",
location: {
regex: new RegExp(`^${URL_PREFIX}/queries/`),
pathname: PATHS.MANAGE_QUERIES,
},
},
];
const policiesTab = [
{
icon: "policies",
name: "Schedule",
location: {
regex: new RegExp(`^${URL_PREFIX}/(schedule|packs)/`),
pathname: PATHS.MANAGE_SCHEDULE,
},
exclude: !isMaintainerOrAdmin,
},
{
name: "Policies",
iconName: "policies",
location: {
regex: new RegExp(`^${URL_PREFIX}/(policies)/`),
pathname: PATHS.MANAGE_POLICIES,
@ -81,34 +93,14 @@ export default (
},
];
const maintainerOrAdminNavItems = [
{
icon: "packs",
name: "Schedule",
iconName: "packs",
location: {
regex: new RegExp(`^${URL_PREFIX}/(schedule|packs)/`),
pathname: PATHS.MANAGE_SCHEDULE,
},
},
];
if (
isGlobalMaintainer ||
isAnyTeamMaintainer ||
isGlobalAdmin ||
isAnyTeamAdmin
) {
return [
...logo,
...userNavItems,
...maintainerOrAdminNavItems,
...policiesTab,
];
}
if (isNoAccess) {
return [...logo];
}
return [...logo, ...userNavItems, ...policiesTab];
return [
...logo,
...navItems.filter((item) => {
return !item.exclude;
}),
];
};

View File

@ -111,7 +111,7 @@ const CoreLayout = ({ children, router }: ICoreLayoutProps) => {
<p>Please enlarge your browser or try again on a computer.</p>
</div>
</div>
<nav className="site-nav">
<nav className="site-nav-container">
<SiteTopNav
config={config}
onLogoutUser={onLogoutUser}

View File

@ -31,10 +31,9 @@
}
}
.site-nav {
background: $gradients-dark-gradient;
.site-nav-container {
background: $core-fleet-black;
box-sizing: border-box;
display: flex;
top: 0;
left: 0;
z-index: 100;

View File

@ -0,0 +1,164 @@
import React, { useContext } from "react";
import { Tab, Tabs, TabList } from "react-tabs";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import mdmAppleAPI from "services/entities/mdm_apple";
import TabsWrapper from "components/TabsWrapper";
import MainContent from "components/MainContent";
import TeamsDropdownHeader, {
ITeamsDropdownState,
} from "components/PageHeader/TeamsDropdownHeader";
import EmptyTable from "components/EmptyTable";
import Button from "components/buttons/Button";
import { IMdmApple } from "interfaces/mdm";
import { find } from "lodash";
import { useQuery } from "react-query";
import Spinner from "components/Spinner";
interface IControlsSubNavItem {
name: string;
pathname: string;
}
const controlsSubNav: IControlsSubNavItem[] = [
{
name: "macOS updates",
pathname: PATHS.CONTROLS_MAC_UPDATES,
},
{
name: "macOS settings",
pathname: PATHS.CONTROLS_MAC_SETTINGS,
},
];
interface IControlsWrapperProp {
children: JSX.Element;
location: any; // no type in react-router v3
router: InjectedRouter; // v3
}
const getTabIndex = (path: string): number => {
return controlsSubNav.findIndex((navItem) => {
// tab stays highlighted for paths that start with same pathname
return path.startsWith(navItem.pathname);
});
};
const baseClass = "controls-wrapper";
const ControlsWrapper = ({
children,
location,
router,
}: IControlsWrapperProp): JSX.Element => {
const { availableTeams, isPremiumTier, setCurrentTeam } = useContext(
AppContext
);
const {
data: mdmApple,
isLoading: isLoadingMdmApple,
error: errorMdmApple,
} = useQuery<IMdmApple, Error, IMdmApple>(
["mdmAppleAPI"],
() => mdmAppleAPI.getAppleAPNInfo(),
{
enabled: isPremiumTier,
staleTime: 5000,
}
);
const navigateToNav = (i: number): void => {
const navPath = controlsSubNav[i].pathname;
router.push(navPath);
};
const handleTeamSelect = (ctx: ITeamsDropdownState) => {
const teamId = ctx.teamId;
const queryString = teamId === undefined ? "" : `?team_id=${teamId}`;
router.replace(location.pathname + queryString);
const selectedTeam = find(availableTeams, ["id", teamId]);
setCurrentTeam(selectedTeam);
};
const renderHeader = () => (
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<div className={`${baseClass}__title`}>
<TeamsDropdownHeader
router={router}
location={location}
baseClass={baseClass}
defaultTitle="Controls"
onChange={handleTeamSelect}
description={() => {
return null;
}}
includeNoTeams
includeAll={false}
/>
</div>
</div>
</div>
);
const onConnectClick = () => router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
const renderBody = () => {
if (isLoadingMdmApple) {
return <Spinner />;
}
return mdmApple ? (
<div>
<TabsWrapper>
<Tabs
selectedIndex={getTabIndex(location.pathname)}
onSelect={(i) => navigateToNav(i)}
>
<TabList>
{controlsSubNav.map((navItem) => {
return (
<Tab key={navItem.name} data-text={navItem.name}>
{navItem.name}
</Tab>
);
})}
</TabList>
</Tabs>
</TabsWrapper>
{children}
</div>
) : (
<EmptyTable
header="Manage your macOS hosts"
info="Connect Fleet to the Apple Push Certificates Portal to get started."
primaryButton={
<Button
variant="brand"
onClick={onConnectClick}
className={`${baseClass}__connectAPC-button`}
>
Connect
</Button>
}
/>
);
};
return (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-wrap`}>{renderHeader()}</div>
{renderBody()}
</div>
</MainContent>
);
};
export default ControlsWrapper;

View File

View File

@ -0,0 +1 @@
export { default } from "./ControlsWrapper";

View File

@ -1 +0,0 @@
export { default } from "./AppSettingsPage";

View File

@ -16,13 +16,13 @@ import Spinner from "components/Spinner";
import SideNav from "../components/SideNav";
import ORG_SETTINGS_NAV_ITEMS from "./OrgSettingsNavItems";
interface IAppSettingsPageProps {
interface IOrgSettingsPageProps {
params: Params;
}
export const baseClass = "app-settings";
export const baseClass = "org-settings";
const AppSettingsPage = ({ params }: IAppSettingsPageProps) => {
const OrgSettingsPage = ({ params }: IOrgSettingsPageProps) => {
const { section } = params;
const DEFAULT_SETTINGS_SECTION = ORG_SETTINGS_NAV_ITEMS[0];
@ -152,4 +152,4 @@ const AppSettingsPage = ({ params }: IAppSettingsPageProps) => {
);
};
export default AppSettingsPage;
export default OrgSettingsPage;

View File

@ -1,5 +1,4 @@
.app-settings {
.org-settings {
&__page-description {
font-size: $x-small;
color: $core-fleet-black;
@ -11,7 +10,6 @@
}
&__side-nav {
.org-settings-form {
.form-field__label {
.buttons {

View File

@ -0,0 +1 @@
export { default } from "./OrgSettingsPage";

View File

@ -1,7 +1,7 @@
import React from "react";
import classnames from "classnames";
import { IAppConfigFormProps } from "pages/admin/AppSettingsPage/cards/constants";
import { IAppConfigFormProps } from "pages/admin/OrgSettingsPage/cards/constants";
import SideNavItem from "../SideNavItem";

View File

@ -378,105 +378,3 @@
width: $col-md;
}
}
.site-nav-item {
position: relative;
transition: color 200ms ease-in-out;
cursor: pointer;
max-height: 50px;
&:hover {
background-color: $core-fleet-black;
}
&--multiple.site-nav-item--active {
background-color: transparent;
border-right: 0;
&:hover {
background-color: transparent;
}
}
&__icon {
position: relative;
font-size: $large;
margin-right: $pad-small;
width: 16px;
height: 16px;
vertical-align: sub;
}
&__name {
display: inline-flex;
flex-direction: column;
align-items: center;
text-decoration: none;
vertical-align: middle;
font-weight: $regular;
font-size: $x-small;
// Bolding text when the button is active causes a layout shift
// so we add a hidden pseudo element with the same text string
&:before {
content: attr(data-text);
height: 0;
visibility: hidden;
overflow: hidden;
user-select: none;
pointer-events: none;
font-weight: $bold;
}
}
&__link {
color: $core-white;
text-align: center;
display: flex;
align-items: center;
padding: 14px 20px 17px;
text-decoration: none;
}
&__logo {
text-align: center;
display: table-cell;
vertical-align: middle;
width: 64px;
}
&--active {
border-bottom: 3px solid $core-vibrant-blue;
background-color: $core-fleet-black;
height: 47px;
&:hover {
background-color: $core-fleet-black;
}
.site-nav-item__name {
font-weight: $bold;
}
}
}
.logo {
height: 48px;
transform: scale(0.5);
position: relative;
top: 1px;
}
.site-nav-container {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.site-nav-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
}

View File

@ -8,7 +8,7 @@ import {
Router,
} from "react-router";
import AppSettingsPage from "pages/admin/AppSettingsPage";
import OrgSettingsPage from "pages/admin/OrgSettingsPage";
import AdminIntegrationsPage from "pages/admin/IntegrationsPage";
import AdminUserManagementPage from "pages/admin/UserManagementPage";
import AdminTeamManagementPage from "pages/admin/TeamManagementPage";
@ -44,6 +44,7 @@ import Fleet403 from "pages/errors/Fleet403";
import Fleet404 from "pages/errors/Fleet404";
import UserSettingsPage from "pages/UserSettingsPage";
import SettingsWrapper from "pages/admin/SettingsWrapper/SettingsWrapper";
import ControlsWrapper from "pages/ControlsPage/ControlsWrapper";
import MembersPage from "pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPage";
import AgentOptionsPage from "pages/admin/TeamManagementPage/TeamDetailsWrapper/AgentOptionsPage";
import PATHS from "router/paths";
@ -74,6 +75,22 @@ const AppWrapper = ({ children, location }: IAppWrapperProps) => (
</AppProvider>
);
// TODO: Replace below elements with the real thing
const MacUpdatesPage = () => {
return (
<div>
<h1>MacUpdates!</h1>
</div>
);
};
const MacSettingsPage = () => {
return (
<div>
<h1>MacSettings!</h1>
</div>
);
};
const routes = (
<Router history={browserHistory}>
<Route path={PATHS.ROOT} component={AppWrapper}>
@ -108,10 +125,10 @@ const routes = (
<IndexRedirect to={"organization"} />
<Route component={SettingsWrapper}>
<Route component={AuthGlobalAdminRoutes}>
<Route path="organization" component={AppSettingsPage} />
<Route path="organization" component={OrgSettingsPage} />
<Route
path="organization/:section"
component={AppSettingsPage}
component={OrgSettingsPage}
/>
<Route path="integrations" component={AdminIntegrationsPage} />
<Route
@ -157,6 +174,15 @@ const routes = (
</Route>
</Route>
</Route>
<Route path="controls" component={AuthAnyMaintainerAnyAdminRoutes}>
<IndexRedirect to={"mac-updates"} />
<Route component={ControlsWrapper}>
<Route path="mac-updates" component={MacUpdatesPage} />
<Route path="mac-settings" component={MacSettingsPage} />
</Route>
</Route>
<Route path="software">
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManageSoftwarePage} />

View File

@ -5,6 +5,9 @@ import URL_PREFIX from "./url_prefix";
export default {
ROOT: `${URL_PREFIX}/`,
CONTROLS: `${URL_PREFIX}/controls`,
CONTROLS_MAC_UPDATES: `${URL_PREFIX}/controls/mac-updates`,
CONTROLS_MAC_SETTINGS: `${URL_PREFIX}/controls/mac-settings`,
DASHBOARD: `${URL_PREFIX}/dashboard`,
DASHBOARD_LINUX: `${URL_PREFIX}/dashboard/linux`,
DASHBOARD_MAC: `${URL_PREFIX}/dashboard/mac`,

View File

@ -6,6 +6,7 @@ $core-vibrant-red: #ff5c83;
$core-fleet-purple: #ae6ddf;
$core-white: #ffffff;
$core-dark-blue-grey: #506e92;
$site-nav-on-hover: #0e1533;
// UI
$ui-fleet-black-75: #515774;