github.com/minio/console@v1.4.1/web-app/src/screens/Console/CommandBar.tsx (about) 1 // This file is part of MinIO Console Server 2 // Copyright (c) 2022 MinIO, Inc. 3 // 4 // This program is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU Affero General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // This program is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU Affero General Public License for more details. 13 // 14 // You should have received a copy of the GNU Affero General Public License 15 // along with this program. If not, see <http://www.gnu.org/licenses/>. 16 import * as React from "react"; 17 import { useCallback, useEffect, useState } from "react"; 18 import { useNavigate } from "react-router-dom"; 19 import { 20 ActionId, 21 ActionImpl, 22 KBarAnimator, 23 KBarPortal, 24 KBarPositioner, 25 KBarResults, 26 KBarSearch, 27 KBarState, 28 useKBar, 29 useMatches, 30 useRegisterActions, 31 } from "kbar"; 32 import { Action } from "kbar/lib/types"; 33 import { routesAsKbarActions } from "./kbar-actions"; 34 35 import { Box, MenuExpandedIcon } from "mds"; 36 import { useSelector } from "react-redux"; 37 import { selFeatures } from "./consoleSlice"; 38 import { Bucket } from "../../api/consoleApi"; 39 import { api } from "../../api"; 40 41 const searchStyle = { 42 padding: "12px 16px", 43 width: "100%", 44 boxSizing: "border-box" as React.CSSProperties["boxSizing"], 45 outline: "none", 46 border: "none", 47 color: "#858585", 48 boxShadow: "0px 3px 5px #00000017", 49 borderRadius: "4px 4px 0px 0px", 50 fontSize: "14px", 51 backgroundImage: "url(/images/search-icn.svg)", 52 backgroundRepeat: "no-repeat", 53 backgroundPosition: "95%", 54 }; 55 56 const animatorStyle = { 57 maxWidth: "600px", 58 width: "100%", 59 background: "white", 60 color: "black", 61 borderRadius: "4px", 62 overflow: "hidden", 63 boxShadow: "0px 3px 20px #00000055", 64 }; 65 66 const groupNameStyle = { 67 marginLeft: "30px", 68 padding: "19px 0px 14px 0px", 69 fontSize: "10px", 70 textTransform: "uppercase" as const, 71 color: "#858585", 72 borderBottom: "1px solid #eaeaea", 73 }; 74 75 const KBarStateChangeMonitor = ({ 76 onShow, 77 onHide, 78 }: { 79 onShow?: () => void; 80 onHide?: () => void; 81 }) => { 82 const [isOpen, setIsOpen] = useState(false); 83 const { visualState } = useKBar((state: KBarState) => { 84 return { 85 visualState: state.visualState, 86 }; 87 }); 88 89 useEffect(() => { 90 if (visualState === "showing") { 91 setIsOpen(true); 92 } else { 93 setIsOpen(false); 94 } 95 // eslint-disable-next-line react-hooks/exhaustive-deps 96 }, [visualState]); 97 98 useEffect(() => { 99 if (isOpen) { 100 onShow?.(); 101 } else { 102 onHide?.(); 103 } 104 // eslint-disable-next-line react-hooks/exhaustive-deps 105 }, [isOpen]); 106 107 //just to hook into the internal state of KBar. ! 108 return null; 109 }; 110 111 const CommandBar = () => { 112 const features = useSelector(selFeatures); 113 const navigate = useNavigate(); 114 115 const [buckets, setBuckets] = useState<Bucket[]>([]); 116 117 const invokeListBucketsApi = () => { 118 api.buckets.listBuckets().then((res) => { 119 if (res.data !== undefined) { 120 setBuckets(res.data.buckets || []); 121 } 122 }); 123 }; 124 125 const fetchBuckets = useCallback(() => { 126 invokeListBucketsApi(); 127 // eslint-disable-next-line react-hooks/exhaustive-deps 128 }, []); 129 130 const initialActions: Action[] = routesAsKbarActions( 131 buckets, 132 navigate, 133 features, 134 ); 135 136 useRegisterActions(initialActions, [buckets, features]); 137 138 //fetch buckets everytime the kbar is shown so that new buckets created elsewhere , within first page is also shown 139 140 return ( 141 <KBarPortal> 142 <KBarStateChangeMonitor 143 onShow={fetchBuckets} 144 onHide={() => { 145 setBuckets([]); 146 }} 147 /> 148 <KBarPositioner 149 style={{ 150 zIndex: 9999, 151 boxShadow: "0px 3px 20px #00000055", 152 borderRadius: "4px", 153 }} 154 > 155 <KBarAnimator style={animatorStyle}> 156 <KBarSearch style={searchStyle} /> 157 <RenderResults /> 158 </KBarAnimator> 159 </KBarPositioner> 160 </KBarPortal> 161 ); 162 }; 163 164 function RenderResults() { 165 const { results, rootActionId } = useMatches(); 166 167 return ( 168 <KBarResults 169 items={results} 170 onRender={({ item, active }) => 171 typeof item === "string" ? ( 172 <Box style={groupNameStyle}>{item}</Box> 173 ) : ( 174 <ResultItem 175 action={item} 176 active={active} 177 currentRootActionId={`${rootActionId}`} 178 /> 179 ) 180 } 181 /> 182 ); 183 } 184 185 const ResultItem = React.forwardRef( 186 ( 187 { 188 action, 189 active, 190 currentRootActionId, 191 }: { 192 action: ActionImpl; 193 active: boolean; 194 currentRootActionId: ActionId; 195 }, 196 ref: React.Ref<HTMLDivElement>, 197 ) => { 198 const ancestors = React.useMemo(() => { 199 if (!currentRootActionId) return action.ancestors; 200 const index = action.ancestors.findIndex( 201 (ancestor) => ancestor.id === currentRootActionId, 202 ); 203 // +1 removes the currentRootAction; e.g. 204 // if we are on the "Set theme" parent action, 205 // the UI should not display "Set theme⦠> Dark" 206 // but rather just "Dark" 207 return action.ancestors.slice(index + 1); 208 }, [action.ancestors, currentRootActionId]); 209 210 return ( 211 <div 212 ref={ref} 213 style={{ 214 padding: "12px 12px 12px 36px", 215 marginTop: "2px", 216 background: active ? "#dddddd" : "transparent", 217 display: "flex", 218 alignItems: "center", 219 justifyContent: "space-between", 220 cursor: "pointer", 221 }} 222 > 223 <Box 224 sx={{ 225 display: "flex", 226 gap: "8px", 227 alignItems: "center", 228 fontSize: 14, 229 flex: 1, 230 justifyContent: "space-between", 231 "& .min-icon": { 232 width: "17px", 233 height: "17px", 234 }, 235 }} 236 > 237 <Box sx={{ height: "15px", width: "15px", marginRight: "36px" }}> 238 {action.icon && action.icon} 239 </Box> 240 <div style={{ display: "flex", flexDirection: "column", flex: 2 }}> 241 <Box> 242 {ancestors.length > 0 && 243 ancestors.map((ancestor) => ( 244 <React.Fragment key={ancestor.id}> 245 <span 246 style={{ 247 opacity: 0.5, 248 marginRight: 8, 249 }} 250 > 251 {ancestor.name} 252 </span> 253 <span 254 style={{ 255 marginRight: 8, 256 }} 257 > 258 › 259 </span> 260 </React.Fragment> 261 ))} 262 <span>{action.name}</span> 263 </Box> 264 {action.subtitle && ( 265 <span 266 style={{ 267 fontSize: 12, 268 }} 269 > 270 {action.subtitle} 271 </span> 272 )} 273 </div> 274 <Box 275 sx={{ 276 "& .min-icon": { 277 width: "15px", 278 height: "15px", 279 fill: "#8f8b8b", 280 transform: "rotate(90deg)", 281 282 "& rect": { 283 fill: "#ffffff", 284 }, 285 }, 286 }} 287 > 288 <MenuExpandedIcon /> 289 </Box> 290 </Box> 291 {action.shortcut?.length ? ( 292 <div 293 aria-hidden 294 style={{ display: "grid", gridAutoFlow: "column", gap: "4px" }} 295 > 296 {action.shortcut.map((sc) => ( 297 <kbd 298 key={sc} 299 style={{ 300 padding: "4px 6px", 301 background: "rgba(0 0 0 / .1)", 302 borderRadius: "4px", 303 fontSize: 14, 304 }} 305 > 306 {sc} 307 </kbd> 308 ))} 309 </div> 310 ) : null} 311 </div> 312 ); 313 }, 314 ); 315 316 export default CommandBar;