Skip to content

Commit

Permalink
web: add custom navbar button support (#4723)
Browse files Browse the repository at this point in the history
Render `Global:nav` buttons in the global nav. User-defined buttons
show up first, then the built-ins (Tilt version, snapshot, help,
account).

The backend enforces that `Global:nav` buttons have an icon defined,
though there is no practical way on either backend or frontend to
determine that the icon name is valid; if it's not a real material
icon, you'll get a blank space, so it should be fairly straightforward
to debug regardless.

Log output (via backend) ends up in the `(global)` span viewable by
"All Resources" in the web interface.
  • Loading branch information
milas committed Jul 2, 2021
1 parent 8def4dc commit cd45491
Show file tree
Hide file tree
Showing 8 changed files with 133 additions and 65 deletions.
1 change: 1 addition & 0 deletions web/public/index.html
Expand Up @@ -4,6 +4,7 @@
<meta charset="utf-8">
<link id="favicon" rel="shortcut icon" href="/favicon.ico">
<link href="https://fonts.googleapis.com/css?family=Inconsolata:400,700|Montserrat:400,600" rel="stylesheet">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<title>Tilt</title>
Expand Down
63 changes: 63 additions & 0 deletions web/src/ApiButton.tsx
@@ -0,0 +1,63 @@
import { Icon } from "@material-ui/core"
import moment from "moment"
import React, { useState } from "react"
import { InstrumentedButton } from "./instrumentedComponents"

type UIButton = Proto.v1alpha1UIButton

type ApiButtonProps = { className?: string; button: UIButton }

export const ApiButton: React.FC<ApiButtonProps> = (props) => {
const [loading, setLoading] = useState(false)
const onClick = async () => {
const toUpdate = {
metadata: { ...props.button.metadata },
status: { ...props.button.status },
} as UIButton
// apiserver's date format time is _extremely_ strict to the point that it requires the full
// six-decimal place microsecond precision, e.g. .000Z will be rejected, it must be .000000Z
// so use an explicit RFC3339 moment format to ensure it passes
toUpdate.status!.lastClickedAt = moment().format(
"YYYY-MM-DDTHH:mm:ss.SSSSSSZ"
)

// TODO(milas): currently the loading state just disables the button for the duration of
// the AJAX request to avoid duplicate clicks - there is no progress tracking at the
// moment, so there's no fancy spinner animation or propagation of result of action(s)
// that occur as a result of click right now
setLoading(true)
const url = `/proxy/apis/tilt.dev/v1alpha1/uibuttons/${
toUpdate.metadata!.name
}/status`
try {
await fetch(url, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(toUpdate),
})
} finally {
setLoading(false)
}
}
// button text is not included in analytics name since that can be user data
return (
<InstrumentedButton
analyticsName={"ui.web.uibutton"}
onClick={onClick}
disabled={loading}
className={props.className}
>
{props.children || (
<>
{props.button.spec?.iconName && (
<Icon>{props.button.spec?.iconName}</Icon>
)}
{props.button.spec?.text ?? "Button"}
</>
)}
</InstrumentedButton>
)
}
37 changes: 37 additions & 0 deletions web/src/CustomNav.tsx
@@ -0,0 +1,37 @@
import { Icon } from "@material-ui/core"
import React from "react"
import styled from "styled-components"
import { ApiButton } from "./ApiButton"
import { MenuButtonLabel, MenuButtonMixin } from "./GlobalNav"

type CustomNavProps = {
view: Proto.webviewView
}

const CustomNavButton = styled(ApiButton)`
${MenuButtonMixin}
.apibtn-label {
display: none;
}
`

export function CustomNav(props: CustomNavProps) {
const buttons =
props.view.uiButtons?.filter(
(b) =>
b.spec?.location &&
(b.spec.location.componentType ?? "").toLowerCase() === "global" &&
(b.spec.location.componentID ?? "").toLowerCase() === "nav"
) || []

return (
<React.Fragment>
{buttons.map((b) => (
<CustomNavButton key={b.metadata?.name} button={b}>
<Icon>{b.spec?.iconName}</Icon>
<MenuButtonLabel>{b.spec?.text}</MenuButtonLabel>
</CustomNavButton>
))}
</React.Fragment>
)
}
28 changes: 15 additions & 13 deletions web/src/GlobalNav.tsx
Expand Up @@ -25,7 +25,16 @@ export const GlobalNavRoot = styled.div`
display: flex;
align-items: stretch;
`
export const MenuButton = styled.button`
export const MenuButtonLabel = styled.div`
position: absolute;
bottom: 0;
font-size: ${FontSize.smallest};
color: ${Color.blueDark};
width: 200%;
transition: opacity ${AnimDuration.default} ease;
opacity: 0;
`
export const MenuButtonMixin = `
${mixinResetButtonStyle};
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -55,21 +64,14 @@ export const MenuButton = styled.button`
pointer-events: none;
cursor: default;
}
`
const MenuButtonLabel = styled.div`
position: absolute;
bottom: 0;
font-size: ${FontSize.smallest};
color: ${Color.blueDark};
width: 200%;
transition: opacity ${AnimDuration.default} ease;
opacity: 0;
${MenuButton}:hover &,
${MenuButton}[data-open="true"] & {
&:hover ${MenuButtonLabel}, &[data-open="true"] ${MenuButtonLabel} {
opacity: 1;
}
`
export const MenuButton = styled.button`
${MenuButtonMixin}
`
const UpdateAvailableFloatIcon = styled(UpdateAvailableIcon)`
display: none;
position: absolute;
Expand Down
2 changes: 2 additions & 0 deletions web/src/HeaderBar.tsx
Expand Up @@ -2,6 +2,7 @@ import React from "react"
import { Link } from "react-router-dom"
import styled from "styled-components"
import { ReactComponent as LogoWordmarkSvg } from "./assets/svg/logo-wordmark.svg"
import { CustomNav } from "./CustomNav"
import { GlobalNav } from "./GlobalNav"
import { usePathBuilder } from "./PathBuilder"
import {
Expand Down Expand Up @@ -93,6 +94,7 @@ export default function HeaderBar(props: HeaderBarProps) {
All Resources
</AllResourcesLink>
<ResourceStatusSummary view={props.view} />
<CustomNav view={props.view} />
<GlobalNav {...globalNavProps} />
</HeaderBarRoot>
)
Expand Down
61 changes: 11 additions & 50 deletions web/src/OverviewActionBar.tsx
Expand Up @@ -10,12 +10,12 @@ import { PopoverOrigin } from "@material-ui/core/Popover"
import { makeStyles } from "@material-ui/core/styles"
import ExpandMoreIcon from "@material-ui/icons/ExpandMore"
import { History } from "history"
import moment from "moment"
import React, { ChangeEvent, useEffect, useRef, useState } from "react"
import { useHistory, useLocation } from "react-router"
import styled from "styled-components"
import { Alert } from "./alerts"
import { incr } from "./analytics"
import { ApiButton } from "./ApiButton"
import { ReactComponent as AlertSvg } from "./assets/svg/alert.svg"
import { ReactComponent as CheckmarkSvg } from "./assets/svg/checkmark.svg"
import { ReactComponent as CloseSvg } from "./assets/svg/close.svg"
Expand Down Expand Up @@ -176,7 +176,7 @@ function FilterSourceMenu(props: FilterSourceMenuProps) {
)
}

let ButtonRoot = styled(InstrumentedButton)`
const OverviewButtonMixin = `
font-family: ${Font.sansSerif};
display: flex;
align-items: center;
Expand Down Expand Up @@ -237,6 +237,14 @@ let ButtonRoot = styled(InstrumentedButton)`
}
`

const ButtonRoot = styled(InstrumentedButton)`
${OverviewButtonMixin}
`

const CustomActionButton = styled(ApiButton)`
${OverviewButtonMixin}
`

const WidgetRoot = styled.div`
display: flex;
${ButtonRoot} + ${ButtonRoot} {
Expand Down Expand Up @@ -673,53 +681,6 @@ function openEndpointUrl(url: string) {
window.open(url, url)
}

function ApiButton(props: { button: UIButton }) {
const [loading, setLoading] = useState(false)
const onClick = async () => {
const toUpdate = {
metadata: { ...props.button.metadata },
status: { ...props.button.status },
} as UIButton
// apiserver's date format time is _extremely_ strict to the point that it requires the full
// six-decimal place microsecond precision, e.g. .000Z will be rejected, it must be .000000Z
// so use an explicit RFC3339 moment format to ensure it passes
toUpdate.status!.lastClickedAt = moment().format(
"YYYY-MM-DDTHH:mm:ss.SSSSSSZ"
)

// TODO(milas): currently the loading state just disables the button for the duration of
// the AJAX request to avoid duplicate clicks - there is no progress tracking at the
// moment, so there's no fancy spinner animation or propagation of result of action(s)
// that occur as a result of click right now
setLoading(true)
const url = `/proxy/apis/tilt.dev/v1alpha1/uibuttons/${
toUpdate.metadata!.name
}/status`
try {
await fetch(url, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(toUpdate),
})
} finally {
setLoading(false)
}
}
// button text is not included in analytics name since that can be user data
return (
<ButtonRoot
analyticsName={"ui.web.uibutton"}
onClick={onClick}
disabled={loading}
>
{props.button.spec?.text ?? "Button"}
</ButtonRoot>
)
}

export function OverviewWidgets(props: { buttons?: UIButton[] }) {
if (!props.buttons?.length) {
return null
Expand All @@ -728,7 +689,7 @@ export function OverviewWidgets(props: { buttons?: UIButton[] }) {
return (
<WidgetRoot key="widgets">
{props.buttons?.map((b) => (
<ApiButton button={b} key={b.metadata?.name} />
<CustomActionButton button={b} key={b.metadata?.name} />
))}
</WidgetRoot>
)
Expand Down
2 changes: 1 addition & 1 deletion web/src/instrumentedComponents.tsx
@@ -1,4 +1,4 @@
import { LegacyRef } from "react"
import React, { LegacyRef } from "react"
import { incr, Tags } from "./analytics"

export type InstrumentedButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
Expand Down
4 changes: 3 additions & 1 deletion web/src/view.d.ts
Expand Up @@ -484,9 +484,11 @@ declare namespace Proto {
*/
iconName?: string;
/**
* IconSVG is an SVG to use as the icon to appear next to button text or on the button itself (dependong on button
* IconSVG is an SVG to use as the icon to appear next to button text or on the button itself (depending on button
* location).
*
* This should be an <svg> element scaled for a 24x24 viewport.
*
* If both IconSVG and IconName are specified, IconSVG will take precedence.
*
* +optional
Expand Down

0 comments on commit cd45491

Please sign in to comment.