From b4888e7e6fb8ec4c5760efaefc49bea08d05bdca Mon Sep 17 00:00:00 2001 From: Matt Landis Date: Mon, 5 Apr 2021 15:49:56 -0400 Subject: [PATCH] web: add resource name filter (#4379) --- web/package.json | 1 + web/src/GlobalNav.tsx | 4 + web/src/HUD.tsx | 34 ++--- web/src/LocalStorage.tsx | 21 ++- .../OverviewActionBarKeyboardShortcuts.tsx | 5 + web/src/OverviewSidebarOptions.test.tsx | 59 ++++++++- web/src/OverviewSidebarOptions.tsx | 123 ++++++++++++++---- web/src/SidebarKeyboardShortcuts.tsx | 5 + web/src/SidebarResources.test.tsx | 53 +++++--- web/src/SidebarResources.tsx | 40 ++++++ web/src/assets/svg/search.svg | 4 + web/src/shortcut.ts | 12 ++ web/src/types.ts | 2 + 13 files changed, 301 insertions(+), 62 deletions(-) create mode 100644 web/src/assets/svg/search.svg create mode 100644 web/src/shortcut.ts diff --git a/web/package.json b/web/package.json index 2a6bbb7245..faa92d8646 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,7 @@ "dependencies": { "@material-ui/core": "^4.11.2", "@material-ui/icons": "^4.11.2", + "@material-ui/styles": "^4.11.3", "@testing-library/dom": "^7.24.5", "@types/history": "^4.7.2", "@types/js-cookie": "^2.2.2", diff --git a/web/src/GlobalNav.tsx b/web/src/GlobalNav.tsx index 6ab5545f38..1327308ff1 100644 --- a/web/src/GlobalNav.tsx +++ b/web/src/GlobalNav.tsx @@ -9,6 +9,7 @@ import { ReactComponent as SnapshotIcon } from "./assets/svg/snapshot.svg" import { ReactComponent as UpdateAvailableIcon } from "./assets/svg/update-available.svg" import FloatDialog from "./FloatDialog" import MetricsDialog from "./MetricsDialog" +import { isTargetEditable } from "./shortcut" import ShortcutsDialog from "./ShortcutsDialog" import { SnapshotAction } from "./snapshot" import { @@ -105,6 +106,9 @@ class GlobalNavShortcuts extends Component { } onKeydown(e: KeyboardEvent) { + if (isTargetEditable(e)) { + return + } if (e.metaKey || e.altKey || e.ctrlKey || e.isComposing) { return } diff --git a/web/src/HUD.tsx b/web/src/HUD.tsx index 28780fd55b..397f3b8712 100644 --- a/web/src/HUD.tsx +++ b/web/src/HUD.tsx @@ -1,3 +1,4 @@ +import { StylesProvider } from "@material-ui/core/styles" import { History, UnregisterCallback } from "history" import React, { Component } from "react" import ReactOutlineManager from "react-outline-manager" @@ -293,21 +294,24 @@ export default class HUD extends Component { } return ( - - - - - ) => ( - - )} - /> - } /> - - - - + /* allow Styled Components to override MUI - https://material-ui.com/guides/interoperability/#controlling-priority-3*/ + + + + + + ) => ( + + )} + /> + } /> + + + + + ) } diff --git a/web/src/LocalStorage.tsx b/web/src/LocalStorage.tsx index 0131a59d35..fa13d94dd5 100644 --- a/web/src/LocalStorage.tsx +++ b/web/src/LocalStorage.tsx @@ -34,20 +34,35 @@ export function accessorsForTesting(name: string) { } // Like `useState`, but backed by localStorage and namespaced by the tiltfileKey -export function usePersistentState(name: string, defaultValue: S) { +// maybeUpgradeSavedState: transforms any state read from storage - allows, e.g., filling in default values for +// fields added since the state was saved +export function usePersistentState( + name: string, + defaultValue: S, + maybeUpgradeSavedState?: (state: S) => S +): [state: S, setState: Dispatch>] { const tiltfileKey = useContext(tiltfileKeyContext) - return useStorageState( + let [state, setState] = useStorageState( localStorage, makeKey(tiltfileKey, name), defaultValue ) + if (maybeUpgradeSavedState) { + state = maybeUpgradeSavedState(state) + } + return [state, setState] } export function PersistentStateProvider(props: { name: string defaultValue: S + maybeUpgradeSavedState?: (state: S) => S children: (state: S, setState: Dispatch>) => JSX.Element }) { - const [state, setState] = usePersistentState(props.name, props.defaultValue) + let [state, setState] = usePersistentState( + props.name, + props.defaultValue, + props.maybeUpgradeSavedState + ) return props.children(state, setState) } diff --git a/web/src/OverviewActionBarKeyboardShortcuts.tsx b/web/src/OverviewActionBarKeyboardShortcuts.tsx index 69ef9f0350..f289cefb74 100644 --- a/web/src/OverviewActionBarKeyboardShortcuts.tsx +++ b/web/src/OverviewActionBarKeyboardShortcuts.tsx @@ -2,6 +2,7 @@ import React, { Component } from "react" import { incr } from "./analytics" import { clearLogs } from "./ClearLogs" import LogStore from "./LogStore" +import { isTargetEditable } from "./shortcut" type Link = Proto.webviewLink @@ -33,6 +34,10 @@ class OverviewActionBarKeyboardShortcuts extends Component { } onKeydown(e: KeyboardEvent) { + if (isTargetEditable(e)) { + return + } + if (e.altKey || e.isComposing) { return } diff --git a/web/src/OverviewSidebarOptions.test.tsx b/web/src/OverviewSidebarOptions.test.tsx index db5a79b611..86b97319a4 100644 --- a/web/src/OverviewSidebarOptions.test.tsx +++ b/web/src/OverviewSidebarOptions.test.tsx @@ -11,10 +11,10 @@ import { AlertsOnTopToggle, FilterOptionList, OverviewSidebarOptions, + ResourceNameFilterTextField, TestsHiddenToggle, TestsOnlyToggle, } from "./OverviewSidebarOptions" -import PathBuilder from "./PathBuilder" import SidebarItemView from "./SidebarItemView" import { SidebarPinContextProvider } from "./SidebarPin" import SidebarResources, { @@ -23,8 +23,6 @@ import SidebarResources, { } from "./SidebarResources" import { SidebarOptions } from "./types" -let pathBuilder = PathBuilder.forTesting("localhost", "/") - const sidebarOptionsAccessor = accessorsForTesting( "sidebar_options" ) @@ -37,7 +35,8 @@ export function assertSidebarItemsAndOptions( names: string[], expectTestsHidden: boolean, expectTestsOnly: boolean, - expectAlertsOnTop: boolean + expectAlertsOnTop: boolean, + expectedResourceNameFilter?: string ) { let sidebar = root.find(SidebarResources) expect(sidebar).toHaveLength(1) @@ -60,6 +59,11 @@ export function assertSidebarItemsAndOptions( expect(optSetter.find(AlertsOnTopToggle).hasClass("is-enabled")).toEqual( expectAlertsOnTop ) + if (expectedResourceNameFilter !== undefined) { + expect(optSetter.find(ResourceNameFilterTextField).props().value).toEqual( + expectedResourceNameFilter + ) + } } function clickTestsHiddenControl(root: ReactWrapper) { @@ -199,6 +203,53 @@ describe("overview sidebar options", () => { expect(pinned).toHaveLength(1) expect(pinned.at(0).props().item.name).toEqual("beep") }) + + it("applies the name filter", () => { + // 'B p' tests both case insensitivity and a multi-term query + sidebarOptionsAccessor.set({ ...defaultOptions, resourceNameFilter: "B p" }) + const root = mount( + + + + {TwoResourcesTwoTests()} + + + + ) + + assertSidebarItemsAndOptions( + root, + ["beep", "boop"], + defaultOptions.testsHidden, + defaultOptions.testsOnly, + defaultOptions.alertsOnTop, + "B p" + ) + }) + + it("says no matches found", () => { + sidebarOptionsAccessor.set({ + ...defaultOptions, + resourceNameFilter: "asdfawfwef", + }) + const root = mount( + + + + {TwoResourcesTwoTests()} + + + + ) + + const resourceSectionItems = root + .find(SidebarListSection) + .find({ name: "resources" }) + .find("li") + expect(resourceSectionItems.map((n) => n.text())).toEqual([ + "No matching resources", + ]) + }) }) it("toggles/untoggles Alerts On Top sorting when button clicked", () => { diff --git a/web/src/OverviewSidebarOptions.tsx b/web/src/OverviewSidebarOptions.tsx index d4a18b2bce..f62673e3f4 100644 --- a/web/src/OverviewSidebarOptions.tsx +++ b/web/src/OverviewSidebarOptions.tsx @@ -1,6 +1,9 @@ -import { makeStyles } from "@material-ui/core/styles" +import { InputAdornment, TextField } from "@material-ui/core" +import { InputProps as StandardInputProps } from "@material-ui/core/Input/Input" import React, { Dispatch, SetStateAction } from "react" import styled from "styled-components" +import { ReactComponent as CloseSvg } from "./assets/svg/close.svg" +import { ReactComponent as SearchSvg } from "./assets/svg/search.svg" import { Color, Font, @@ -14,31 +17,30 @@ import { SidebarOptions } from "./types" const OverviewSidebarOptionsRoot = styled.div` display: flex; justify-content: space-between; - align-items: center; font-family: ${Font.sansSerif}; font-size: ${FontSize.smallester}; padding-left: ${SizeUnit(0.5)}; padding-right: ${SizeUnit(0.5)}; color: ${Color.offWhite}; + flex-direction: column; +` - &.is-filtersHidden { +const OverviewSidebarOptionsButtonsRoot = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + &.is-filterButtonsHidden { justify-content: flex-end; } ` export const FilterOptionList = styled.ul` - ${mixinResetListStyle} + ${mixinResetListStyle}; display: flex; align-items: center; user-select: none; // Prevent unsightly highlighting on the label ` -const useStyles = makeStyles({ - root: { - color: Color.offWhite, - }, -}) - const toggleBorderRadius = "3px" const ResourceFilterSegmentedControls = styled.div` @@ -46,7 +48,7 @@ const ResourceFilterSegmentedControls = styled.div` ` const ResourceFilterToggle = styled.button` - ${mixinResetButtonStyle} + ${mixinResetButtonStyle}; color: ${Color.grayLightest}; background-color: ${Color.gray}; padding: ${SizeUnit(0.125)} ${SizeUnit(0.25)}; @@ -73,7 +75,7 @@ export const TestsOnlyToggle = styled(ResourceFilterToggle)` ` export const AlertsOnTopToggle = styled.button` - ${mixinResetButtonStyle} + ${mixinResetButtonStyle}; color: ${Color.grayLightest}; background-color: ${Color.gray}; padding: ${SizeUnit(0.125)} ${SizeUnit(0.25)}; @@ -86,6 +88,36 @@ export const AlertsOnTopToggle = styled.button` } ` +export const ResourceNameFilterTextField = styled(TextField)` + & .MuiOutlinedInput-root { + border-radius: ${SizeUnit(0.5)}; + border: 1px solid ${Color.grayLighter}; + background-color: ${Color.gray}; + + & fieldset { + border-color: 1px solid ${Color.grayLighter}; + } + &:hover fieldset { + border: 1px solid ${Color.grayLighter}; + } + &.Mui-focused fieldset { + border: 1px solid ${Color.grayLighter}; + } + & .MuiOutlinedInput-input { + padding: ${SizeUnit(0.2)}; + } + } + + margin-top: ${SizeUnit(0.4)}; + margin-bottom: ${SizeUnit(0.4)}; + + & .MuiInputBase-input { + font-family: ${Font.monospace}; + color: ${Color.offWhite}; + font-size: ${FontSize.small}; + } +` + type OverviewSidebarOptionsProps = { showFilters: boolean options: SidebarOptions @@ -125,8 +157,19 @@ function toggleTestsHidden(props: OverviewSidebarOptionsProps) { }) } +function setResourceNameFilter( + newValue: string, + props: OverviewSidebarOptionsProps +) { + props.setOptions((prevOptions) => { + return { + ...prevOptions, + resourceNameFilter: newValue, + } + }) +} + function filterOptions(props: OverviewSidebarOptionsProps) { - const classes = useStyles() return ( Tests: @@ -148,21 +191,55 @@ function filterOptions(props: OverviewSidebarOptionsProps) { ) } +function ResourceNameFilter(props: OverviewSidebarOptionsProps) { + let inputProps: Partial = { + startAdornment: ( + + + + ), + } + + // only show the "x" to clear if there's any input to clear + if (props.options.resourceNameFilter) { + inputProps.endAdornment = ( + + setResourceNameFilter("", props)} + /> + + ) + } + + return ( + setResourceNameFilter(e.target.value, props)} + placeholder="Filter resources by name" + InputProps={inputProps} + variant="outlined" + /> + ) +} + export function OverviewSidebarOptions( props: OverviewSidebarOptionsProps ): JSX.Element { return ( - - {props.showFilters ? filterOptions(props) : null} - setAlertsOnTop(props, !props.options.alertsOnTop)} + + - Alerts on Top - + {props.showFilters ? filterOptions(props) : null} + setAlertsOnTop(props, !props.options.alertsOnTop)} + > + Alerts on Top + + + ) } diff --git a/web/src/SidebarKeyboardShortcuts.tsx b/web/src/SidebarKeyboardShortcuts.tsx index e40bed036b..eb8e3bdc47 100644 --- a/web/src/SidebarKeyboardShortcuts.tsx +++ b/web/src/SidebarKeyboardShortcuts.tsx @@ -1,4 +1,5 @@ import React, { Component } from "react" +import { isTargetEditable } from "./shortcut" import SidebarItem from "./SidebarItem" import { TabNav, useTabNav } from "./TabNav" import { ResourceName, ResourceView } from "./types" @@ -29,6 +30,10 @@ class SidebarKeyboardShortcuts extends Component { } onKeydown(e: KeyboardEvent) { + if (isTargetEditable(e)) { + return + } + if (e.shiftKey || e.altKey || e.isComposing) { return } diff --git a/web/src/SidebarResources.test.tsx b/web/src/SidebarResources.test.tsx index be975467fa..1b2f95d890 100644 --- a/web/src/SidebarResources.test.tsx +++ b/web/src/SidebarResources.test.tsx @@ -6,6 +6,7 @@ import { expectIncr } from "./analytics_test_helpers" import { accessorsForTesting, tiltfileKeyContext } from "./LocalStorage" import { AlertsOnTopToggle, + ResourceNameFilterTextField, TestsHiddenToggle, TestsOnlyToggle, } from "./OverviewSidebarOptions" @@ -142,20 +143,29 @@ describe("SidebarResources", () => { expect(pinnedItemsAccessor.get()).toEqual(["vigoda"]) }) - const loadCases: [string, SidebarOptions, string[]][] = [ + const falseyOptions: SidebarOptions = { + testsHidden: false, + testsOnly: false, + alertsOnTop: false, + resourceNameFilter: "", + } + + const loadCases: [string, any, string[]][] = [ + ["tests only", { ...falseyOptions, testsOnly: true }, ["a", "b"]], + ["tests hidden", { ...falseyOptions, testsHidden: true }, ["vigoda"]], [ - "tests only", - { testsHidden: false, testsOnly: true, alertsOnTop: false }, - ["a", "b"], + "alertsOnTop", + { ...falseyOptions, alertsOnTop: true }, + ["vigoda", "a", "b"], ], [ - "tests hidden", - { testsHidden: true, testsOnly: false, alertsOnTop: false }, + "resourceNameFilter", + { ...falseyOptions, resourceNameFilter: "vig" }, ["vigoda"], ], [ - "alertsOnTop", - { testsHidden: false, testsOnly: false, alertsOnTop: true }, + "resourceNameFilter undefined", + { ...falseyOptions, resourceNameFilter: undefined }, ["vigoda", "a", "b"], ], ] @@ -194,12 +204,10 @@ describe("SidebarResources", () => { ) const saveCases: [string, SidebarOptions][] = [ - ["testsHidden", { testsHidden: true, testsOnly: false, alertsOnTop: true }], - ["testsOnly", { testsHidden: false, testsOnly: true, alertsOnTop: true }], - [ - "alertsOnTop", - { testsHidden: false, testsOnly: false, alertsOnTop: false }, - ], + ["testsHidden", { ...falseyOptions, testsHidden: true }], + ["testsOnly", { ...falseyOptions, testsOnly: true }], + ["alertsOnTop", { ...falseyOptions, alertsOnTop: true }], + ["resourceNameFilter", { ...falseyOptions, resourceNameFilter: "foo" }], ] test.each(saveCases)( "saves option %s to localStorage", @@ -225,23 +233,34 @@ describe("SidebarResources", () => { let testsHiddenControl = root.find(TestsHiddenToggle) if ( - testsHiddenControl.hasClass("is-enabled") != expectedOptions.testsHidden + testsHiddenControl.hasClass("is-enabled") !== + expectedOptions.testsHidden ) { testsHiddenControl.simulate("click") } let testsOnlyControl = root.find(TestsOnlyToggle) if ( - testsOnlyControl.hasClass("is-enabled") != expectedOptions.testsOnly + testsOnlyControl.hasClass("is-enabled") !== expectedOptions.testsOnly ) { testsOnlyControl.simulate("click") } let aotToggle = root.find(AlertsOnTopToggle) - if (aotToggle.hasClass("is-enabled") != expectedOptions.alertsOnTop) { + if (aotToggle.hasClass("is-enabled") !== expectedOptions.alertsOnTop) { aotToggle.simulate("click") } + let resourceNameFilterTextField = root.find(ResourceNameFilterTextField) + if ( + resourceNameFilterTextField.props().value !== + expectedOptions.resourceNameFilter + ) { + resourceNameFilterTextField.find("input").simulate("change", { + target: { value: expectedOptions.resourceNameFilter }, + }) + } + const observedOptions = sidebarOptionsAccessor.get() expect(observedOptions).toEqual(expectedOptions) } diff --git a/web/src/SidebarResources.tsx b/web/src/SidebarResources.tsx index 9f1561b75c..8eb5210c22 100644 --- a/web/src/SidebarResources.tsx +++ b/web/src/SidebarResources.tsx @@ -36,6 +36,11 @@ const SidebarListSectionItems = styled.ul` list-style: none; ` +const NoMatchesFound = styled.li` + margin-left: ${SizeUnit(0.5)}; + color: ${Color.grayLightest}; +` + export function SidebarListSection( props: React.PropsWithChildren<{ name: string }> ): JSX.Element { @@ -61,6 +66,13 @@ export const defaultOptions: SidebarOptions = { testsHidden: false, testsOnly: false, alertsOnTop: false, + resourceNameFilter: "", +} + +function MaybeUpgradeSavedSidebarOptions(o: SidebarOptions) { + // non-nullable fields added to SidebarOptions after its initial release need to have default values + // filled in here + return { ...o, resourceNameFilter: o.resourceNameFilter ?? "" } } function PinnedItems(props: SidebarProps) { @@ -94,6 +106,18 @@ function sortByHasAlerts(itemA: SidebarItem, itemB: SidebarItem): number { return Number(hasAlerts(itemB)) - Number(hasAlerts(itemA)) } +function matchesResourceName(item: SidebarItem, filter: string): boolean { + filter = filter.trim() + // this is functionally redundant but probably an important enough case to make its own thing + if (filter === "") { + return true + } + // a resource matches the query if the resource name contains all tokens in the query + return filter + .split(" ") + .every((token) => item.name.toLowerCase().includes(token.toLowerCase())) +} + export class SidebarResources extends React.Component { constructor(props: SidebarProps) { super(props) @@ -130,6 +154,10 @@ export class SidebarResources extends React.Component { filteredItems = this.props.items.filter((item) => item.isTest) } + filteredItems = filteredItems.filter((item) => + matchesResourceName(item, options.resourceNameFilter) + ) + if (options.alertsOnTop) { filteredItems.sort(sortByHasAlerts) } @@ -150,6 +178,17 @@ export class SidebarResources extends React.Component { ? "isOverview" : "" + // only say no matches if there were actually items that got filtered out + // otherwise, there might just be 0 resources because there are 0 resources + // (though technically there's probably always at least a Tiltfile resource) + if (listItems.length === 0 && this.props.items.length !== 0) { + listItems = [ + + No matching resources + , + ] + } + return ( @@ -182,6 +221,7 @@ export class SidebarResources extends React.Component { {(value: SidebarOptions, set) => this.renderWithOptions(value, set)} diff --git a/web/src/assets/svg/search.svg b/web/src/assets/svg/search.svg new file mode 100644 index 0000000000..dbabc64897 --- /dev/null +++ b/web/src/assets/svg/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/shortcut.ts b/web/src/shortcut.ts new file mode 100644 index 0000000000..720aeb3d0f --- /dev/null +++ b/web/src/shortcut.ts @@ -0,0 +1,12 @@ +// if the user is typing in one of these elements, those key-presses shouldn't trigger shortcuts +const ignoredTags = ["input", "textarea"] + +export function isTargetEditable(e: KeyboardEvent): boolean { + // NB: this disables *all* custom shortcuts while in an editable field, including, e.g. ctrl+bksp. + // We could return false if e.KeyCtrl, but then ctrl+v could trigger a 'v' shortcut, which is bad. + // We can worry about those issues if/when they come up. + return ( + e.target instanceof Element && + ignoredTags.includes(e.target.tagName.toLowerCase()) + ) +} diff --git a/web/src/types.ts b/web/src/types.ts index b7b14c06cc..dad83a5a6a 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -148,4 +148,6 @@ export type SidebarOptions = { // Sorting options alertsOnTop: boolean + + resourceNameFilter: string }