github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/ResourceNav.tsx (about) 1 import React, { 2 useCallback, 3 useContext, 4 useEffect, 5 useMemo, 6 useState, 7 } from "react" 8 import { matchPath, useHistory, useLocation } from "react-router-dom" 9 import { usePathBuilder } from "./PathBuilder" 10 import { ResourceName } from "./types" 11 12 // Resource navigation semantics. 13 // 1. standardizes navigation 14 // 2. saves components from having to jump through hoops to get history + pathbuilder 15 export type ResourceNav = { 16 // The currently selected resource. 17 selectedResource: string 18 19 // Resource provided from the user URL that didn't exist. 20 // Different parts of the UI might display this error differently. 21 invalidResource: string 22 23 // Behavior when you click on a link to a resource. 24 openResource(name: string): void 25 } 26 27 const resourceNavContext = React.createContext<ResourceNav>({ 28 selectedResource: "", 29 invalidResource: "", 30 openResource: (name: string) => {}, 31 }) 32 33 export function useResourceNav(): ResourceNav { 34 return useContext(resourceNavContext) 35 } 36 37 export let ResourceNavContextConsumer = resourceNavContext.Consumer 38 export let ResourceNavContextProvider = resourceNavContext.Provider 39 40 export function ResourceNavProvider( 41 props: React.PropsWithChildren<{ 42 validateResource: (name: string) => boolean 43 }> 44 ) { 45 let validateResource = useCallback( 46 (name: string): boolean => { 47 // The ALL resource should always validate 48 return props.validateResource(name) || name === ResourceName.all 49 }, 50 [props.validateResource] 51 ) 52 53 let history = useHistory() 54 let location = useLocation() 55 let pb = usePathBuilder() 56 let selectedResource = "" 57 let [filterByResource, setFilterByResource] = useState( 58 {} as { [key: string]: string } 59 ) 60 let invalidResource = "" 61 62 let matchResource = matchPath(location.pathname, { 63 path: pb.path("/r/:name"), 64 }) 65 let candidateResource = decodeURIComponent( 66 (matchResource?.params as any)?.name || "" 67 ) 68 if (candidateResource && validateResource(candidateResource)) { 69 selectedResource = candidateResource 70 } else { 71 invalidResource = candidateResource 72 } 73 74 let search = location.search 75 76 useEffect(() => { 77 let existing = filterByResource[selectedResource] || "" 78 if (existing != search) { 79 let obj = {} as { [key: string]: string } 80 Object.assign(obj, filterByResource) 81 obj[selectedResource] = search 82 setFilterByResource(obj) 83 } 84 }, [selectedResource, search]) 85 86 let openResource = useCallback( 87 (name: string) => { 88 name = name || ResourceName.all 89 let url = pb.encpath`/r/${name}/overview` 90 91 // We deliberately make search terms stick to a resource. 92 // 93 // So if you add a log filter to resource A, navigate to B, 94 // then come back to A, we preserve the filter on A. 95 // 96 // We're not sure if this is the right behavior, and do not 97 // store it in any sort of persistent store. 98 let storedFilter = filterByResource[name] || "" 99 history.push(url + storedFilter) 100 }, 101 [history, filterByResource] 102 ) 103 104 let resourceNav = useMemo(() => { 105 return { 106 invalidResource: invalidResource, 107 selectedResource: selectedResource, 108 openResource, 109 } 110 }, [invalidResource, selectedResource, openResource]) 111 112 return ( 113 <resourceNavContext.Provider value={resourceNav}> 114 {props.children} 115 </resourceNavContext.Provider> 116 ) 117 }