UNPKG

@coder/backstage-plugin-coder

Version:

Create and manage Coder workspaces from Backstage

1,032 lines (1,016 loc) 37.5 kB
import React, { useState, useRef, useEffect, Fragment } from 'react'; import { makeStyles } from '@material-ui/core'; import { h as useWorkspacesCardContext, i as useId, V as VisuallyHidden, j as urlSyncApiRef, c as useCoderAppConfig, k as CoderLogo, u as useInternalCoderAuth, R as Root } from './index-e8bd86d5.esm.js'; export { l as CardContext, R as Root, h as useWorkspacesCardContext } from './index-e8bd86d5.esm.js'; import SearchIcon from '@material-ui/icons/Search'; import CloseIcon from '@material-ui/icons/Close'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { useApi } from '@backstage/core-plugin-api'; import AddIcon from '@material-ui/icons/AddCircleOutline'; import Tooltip from '@material-ui/core/Tooltip'; import Menu from '@material-ui/core/Menu'; import MenuItem from '@material-ui/core/MenuItem'; import MoreItemsIcon from '@material-ui/icons/MoreVert'; import 'axios'; import 'ua-parser-js'; import 'valibot'; import '@backstage/integration-react'; import '@backstage/plugin-catalog-react'; import 'react-dom'; import '@tanstack/react-query'; import '@backstage/core-components'; import '@material-ui/core/Dialog'; import '@material-ui/core/DialogContent'; import '@material-ui/core/DialogTitle'; import '@material-ui/core/DialogActions'; import '@material-ui/core/TextField'; import '@material-ui/icons/ErrorOutline'; import '@material-ui/icons/Sync'; const useStyles$9 = makeStyles((theme) => ({ root: { color: theme.palette.text.primary, display: "flex", flexFlow: "row nowrap", alignItems: "center", gap: theme.spacing(1) }, hgroup: { marginRight: "auto" }, header: { fontSize: "1.5rem", lineHeight: 1, margin: 0 }, subheader: { margin: "0", fontSize: "0.875rem", fontWeight: 400, color: theme.palette.text.secondary, paddingTop: theme.spacing(0.5) } })); const HeaderRow = ({ actions, headerLevel, className, headerClassName, hgroupClassName, subheaderClassName, activeRepoFilteringText, headerText = "Coder Workspaces", ...delegatedProps }) => { const { headerId, workspacesConfig } = useWorkspacesCardContext(); const styles = useStyles$9(); const HeadingComponent = headerLevel != null ? headerLevel : "h2"; const { repoUrl } = workspacesConfig; return /* @__PURE__ */ React.createElement("div", { className: `${styles.root} ${className != null ? className : ""}`, ...delegatedProps }, /* @__PURE__ */ React.createElement("hgroup", { className: `${styles.hgroup} ${hgroupClassName != null ? hgroupClassName : ""}` }, /* @__PURE__ */ React.createElement( HeadingComponent, { id: headerId, className: `${styles.header} ${headerClassName != null ? headerClassName : ""}` }, headerText ), repoUrl && /* @__PURE__ */ React.createElement("p", { className: `${styles.subheader} ${subheaderClassName != null ? subheaderClassName : ""}` }, activeRepoFilteringText != null ? activeRepoFilteringText : /* @__PURE__ */ React.createElement(React.Fragment, null, "Results filtered by ", extractRepoName(repoUrl)))), actions); }; const repoNameRe = /^(?:https?:\/\/)?(?:www\.)?(?:github|gitlab|bitbucket)\.com\/.*?\/(.+)?$/i; function extractRepoName(repoUrl) { var _a; const [, repoName] = (_a = repoNameRe.exec(repoUrl)) != null ? _a : []; return repoName ? `repo: ${repoName}` : "repo URL"; } const LABEL_TEXT = "Search your Coder workspaces"; const SEARCH_DEBOUNCE_MS = 400; const useStyles$8 = makeStyles((theme) => ({ root: { padding: 0, margin: 0, border: "none", display: "flex", flexFlow: "row nowrap", alignItems: "center", borderRadius: theme.shape.borderRadius, boxShadow: "none", // There's a weird styling issue where Spotify's default background colors // don't have the same amount of contrast across their built-in light and // dark themes. It's just right for the input in dark mode, but too faint in // light mode. Have to make it darker to make sure input is more obvious backgroundColor: () => { const defaultBackgroundColor = theme.palette.background.default; const isDefaultSpotifyLightTheme = defaultBackgroundColor.toUpperCase() === "#F8F8F8"; return isDefaultSpotifyLightTheme ? "hsl(0deg,0%,93%)" : defaultBackgroundColor; }, "&:focus-within": { boxShadow: "0 0 0 1px hsl(213deg, 94%, 68%)" }, // Makes it so that the container doesn't have visible focus while you're // focusing on the clear button "&:has(button:focus)": { boxShadow: "none" } }, labelWrapper: { flexGrow: 1, display: "flex", flexFlow: "row nowrap", alignItems: "center", gap: theme.spacing(1.5), padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px` }, searchInput: { color: "inherit", display: "block", height: "100%", width: "100%", backgroundColor: "inherit", border: "none", fontSize: theme.typography.body1.fontSize, outline: "none" }, clearButton: ({ isInputEmpty }) => ({ padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, margin: 0, lineHeight: 1, backgroundColor: "inherit", border: "none", borderRadius: theme.shape.borderRadius, color: theme.palette.text.primary, opacity: isInputEmpty ? "40%" : "100%", outline: "none", cursor: "pointer", "&:focus": { boxShadow: "0 0 0 1px hsl(213deg, 94%, 68%)" } }) })); const SearchBox = ({ className, labelWrapperClassName, searchInputClassName, clearButtonClassName, searchInputRef, clearButtonRef, ...delegatedProps }) => { const hookId = useId(); const { queryFilter, onFilterChange } = useWorkspacesCardContext(); const [localInput, setLocalInput] = useState(queryFilter); const isInputEmpty = localInput === ""; const styles = useStyles$8({ isInputEmpty }); const searchDebounceIdRef = useRef(); useEffect(() => { const clearDebounceOnUnmount = () => { window.clearTimeout(searchDebounceIdRef.current); }; return clearDebounceOnUnmount; }, []); const onSearchClear = () => { setLocalInput(""); onFilterChange(""); window.clearTimeout(searchDebounceIdRef.current); }; const onChange = (event) => { const newSearchText = event.currentTarget.value; setLocalInput(newSearchText); const textClearedViaInput = !isInputEmpty && newSearchText === ""; if (textClearedViaInput) { onSearchClear(); return; } window.clearTimeout(searchDebounceIdRef.current); searchDebounceIdRef.current = window.setTimeout(() => { onFilterChange(newSearchText); }, SEARCH_DEBOUNCE_MS); }; const legendId = `${hookId}-legend`; return ( // Have to use aria-labelledby even though <legend>s normally provide // accessible names automatically - the hidden prop on the legend blocks the // default behavior /* @__PURE__ */ React.createElement( "fieldset", { "aria-labelledby": legendId, className: `${styles.root} ${className != null ? className : ""}`, ...delegatedProps }, /* @__PURE__ */ React.createElement("legend", { hidden: true, id: legendId }, "Search controls"), /* @__PURE__ */ React.createElement( "label", { className: `${styles.labelWrapper} ${labelWrapperClassName != null ? labelWrapperClassName : ""}` }, /* @__PURE__ */ React.createElement(SearchIcon, { "aria-hidden": true, fontSize: "small", htmlColor: "#7b7b7b" }), /* @__PURE__ */ React.createElement(VisuallyHidden, null, LABEL_TEXT), /* @__PURE__ */ React.createElement( "input", { ref: searchInputRef, type: "text", role: "searchbox", spellCheck: true, placeholder: LABEL_TEXT, value: localInput, onChange, className: `${styles.searchInput} ${searchInputClassName != null ? searchInputClassName : ""}` } ) ), /* @__PURE__ */ React.createElement( "button", { type: "button", ref: clearButtonRef, onClick: onSearchClear, disabled: isInputEmpty, className: `${styles.clearButton} ${clearButtonClassName != null ? clearButtonClassName : ""}` }, /* @__PURE__ */ React.createElement(CloseIcon, { fontSize: "small" }), /* @__PURE__ */ React.createElement(VisuallyHidden, null, "Clear out search") ) ) ); }; function getWorkspaceAgentStatuses(workspace) { const uniqueStatuses = []; for (const resource of workspace.latest_build.resources) { if (resource.agents === void 0) { continue; } for (const agent of resource.agents) { const status = agent.status; if (!uniqueStatuses.includes(status)) { uniqueStatuses.push(status); } } } return uniqueStatuses; } function useUrlSync() { const urlSyncApi = useApi(urlSyncApiRef); const state = useSyncExternalStore( urlSyncApi.subscribe, urlSyncApi.getCachedUrls ); return { state, renderHelpers: { isEmojiUrl: (url) => { return url.startsWith(`${state.assetsRoute}/emoji`); } } }; } const useStyles$7 = makeStyles((theme) => ({ root: { width: theme.spacing(2.5), height: theme.spacing(2.5), fontSize: "0.625rem", backgroundColor: theme.palette.background.default, borderRadius: "9999px", display: "flex", justifyContent: "center", alignItems: "center", // Necessary to make sure that super-wide workspace names in // WorkspaceListItem don't cause the icon to get squished when the list item // runs out of room flexShrink: 0 }, image: ({ isEmoji }) => { const imageScalePercent = isEmoji ? 65 : 100; return { width: `${imageScalePercent}%`, height: `${imageScalePercent}%`, borderRadius: "9999px" }; } })); const WorkspacesListIcon = ({ src, workspaceName, className, imageClassName, imageRef, ...delegatedProps }) => { const [hasError, setHasError] = useState(false); const { renderHelpers } = useUrlSync(); const styles = useStyles$7({ isEmoji: renderHelpers.isEmojiUrl(src) }); return /* @__PURE__ */ React.createElement( "div", { "aria-hidden": true, className: `${styles.root} ${className != null ? className : ""}`, ...delegatedProps }, hasError ? /* @__PURE__ */ React.createElement("span", { role: "none", "data-testid": "icon-fallback" }, getFirstLetter(workspaceName)) : /* @__PURE__ */ React.createElement( "img", { ref: imageRef, "data-testid": "icon-image", role: "none", src, alt: "", onError: () => setHasError(true), className: `${styles.image} ${imageClassName != null ? imageClassName : ""}` } ) ); }; const firstLetterRe = /([a-zA-Z])/; function getFirstLetter(text) { var _a; const [, firstLetter] = (_a = firstLetterRe.exec(text)) != null ? _a : []; return (firstLetter != null ? firstLetter : "W").toUpperCase(); } const useStyles$6 = makeStyles((theme) => ({ root: { display: "flex", flexFlow: "row nowrap", alignItems: "center", columnGap: theme.spacing(2), margin: `0 -${theme.spacing(2)}px`, borderTop: `1px solid ${theme.palette.divider}`, backgroundColor: "inherit", cursor: "pointer", "&:hover": { backgroundColor: theme.palette.action.hover }, "&:first-child": { borderTop: "none" }, "&:last-child > div": { borderBottom: `1px solid ${theme.palette.divider}` } }, listFlexRow: { display: "flex", flexFlow: "row nowrap", alignItems: "center", width: "100%", gap: theme.spacing(2), padding: `${theme.spacing(1)}px ${theme.spacing(2)}px ${theme.spacing( 1 )}px ${theme.spacing(4)}px` }, link: { fontWeight: 500, color: theme.palette.type, fontSize: theme.typography.body1.fontSize, // All needed to make sure that long names get truncated properly display: "block", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }, onlineStatusContainer: { display: "flex", flexFlow: "row nowrap", alignItems: "center", gap: theme.spacing(1), color: theme.palette.text.secondary, fontSize: "16px" }, onlineStatusLight: ({ isAvailable }) => ({ display: "block", width: theme.spacing(1), height: theme.spacing(1), borderRadius: "9999px", borderWidth: "1px", borderStyle: "solid", // Border color helps increase color contrast in light mode borderColor: isAvailable ? "hsl(130deg,100%,40%)" : theme.palette.common.black, backgroundColor: isAvailable ? "hsl(135deg,100%,77%)" : theme.palette.common.black }), button: { border: `1px solid ${theme.palette.primary.main}`, textTransform: "uppercase", borderRadius: theme.shape.borderRadius, padding: `${theme.spacing(1)}px ${theme.spacing(2.5)}px`, fontWeight: 700, letterSpacing: "0.02em", color: theme.palette.primary.main, backgroundColor: "inherit", flexShrink: 0, "&:hover": { backgroundColor: theme.palette.background.default } } })); const WorkspacesListItem = ({ workspace, className, listFlexRowClassName, linkClassName, onlineStatusContainerClassName, onlineStatusLightClassName, buttonClassName, onClick: outerOnClick, onAuxClick: outerOnAuxClick, onKeyDown: outerOnKeyDown, ...delegatedProps }) => { const hookId = useId(); const { accessUrl } = useCoderAppConfig().deployment; const anchorElementRef = useRef(null); const availabilityStatus = getAvailabilityStatus(workspace); const styles = useStyles$6({ isAvailable: availabilityStatus === "online" || availabilityStatus === "pending" }); const { name, owner_name, template_icon } = workspace; const onlineStatusId = `${hookId}-online-status`; const clickAnchor = () => { var _a; return (_a = anchorElementRef.current) == null ? void 0 : _a.click(); }; return ( /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- Adding event listeners to the list item to increase clickable link area for users. Events will always get "re-routed" to the anchor */ /* @__PURE__ */ React.createElement( "li", { className: `${styles.root} ${className != null ? className : ""}`, onClick: (event) => { clickAnchor(); outerOnClick == null ? void 0 : outerOnClick(event); }, onAuxClick: (event) => { clickAnchor(); outerOnAuxClick == null ? void 0 : outerOnAuxClick(event); }, onKeyDown: (event) => { if (event.key === "Enter") { clickAnchor(); } outerOnKeyDown == null ? void 0 : outerOnKeyDown(event); }, ...delegatedProps }, /* @__PURE__ */ React.createElement("div", { className: `${styles.listFlexRow} ${listFlexRowClassName != null ? listFlexRowClassName : ""}` }, /* @__PURE__ */ React.createElement(WorkspacesListIcon, { src: template_icon, workspaceName: name }), /* @__PURE__ */ React.createElement("div", { style: { flexGrow: 1, minWidth: 0 } }, /* @__PURE__ */ React.createElement( "a", { ref: anchorElementRef, className: `${styles.link} ${linkClassName != null ? linkClassName : ""}`, href: `${accessUrl}/@${owner_name}/${name}`, target: "_blank", "aria-describedby": onlineStatusId, onClick: stopClickEventBubbling }, /* @__PURE__ */ React.createElement(VisuallyHidden, null, "Open workspace for "), name, /* @__PURE__ */ React.createElement(VisuallyHidden, null, " in Coder. (Link opens in new tab.)") ), /* @__PURE__ */ React.createElement( "span", { id: onlineStatusId, className: `${styles.onlineStatusContainer} ${onlineStatusContainerClassName != null ? onlineStatusContainerClassName : ""}` }, /* @__PURE__ */ React.createElement( "span", { role: "none", className: `${styles.onlineStatusLight} ${onlineStatusLightClassName != null ? onlineStatusLightClassName : ""}` } ), /* @__PURE__ */ React.createElement(VisuallyHidden, null, "Workspace is "), availabilityStatus === "deleting" || availabilityStatus === "pending" ? /* @__PURE__ */ React.createElement(React.Fragment, null, toUppercase(availabilityStatus), "\u2026") : /* @__PURE__ */ React.createElement(React.Fragment, null, toUppercase(availabilityStatus), /* @__PURE__ */ React.createElement(VisuallyHidden, null, ".")) )), /* @__PURE__ */ React.createElement( "span", { "aria-hidden": true, className: `${styles.button} ${buttonClassName != null ? buttonClassName : ""}` }, "Open" )) ) ); }; const deletingStatuses = ["deleting", "deleted"]; const offlineStatuses = [ "stopped", "stopping", "pending", "canceling", "canceled" ]; function getAvailabilityStatus(workspace) { const currentStatus = workspace.latest_build.status; if (currentStatus === "failed") { return "failed"; } if (deletingStatuses.includes(currentStatus)) { return "deleting"; } if (offlineStatuses.includes(currentStatus)) { return "offline"; } const uniqueStatuses = getWorkspaceAgentStatuses(workspace); const isPending = currentStatus === "starting" || uniqueStatuses.some((status) => status === "connecting"); if (isPending) { return "pending"; } return uniqueStatuses.every((status) => status === "connected") ? "online" : "offline"; } function stopClickEventBubbling(event) { const { nativeEvent } = event; const shouldStopBubbling = nativeEvent instanceof MouseEvent || nativeEvent instanceof KeyboardEvent && nativeEvent.key === "Enter"; if (shouldStopBubbling) { event.stopPropagation(); } } function toUppercase(s) { return s.slice(0, 1).toUpperCase() + s.slice(1).toLowerCase(); } const usePlaceholderStyles = makeStyles((theme) => ({ root: { padding: `${theme.spacing(4)}px 0 ${theme.spacing(5)}px`, display: "flex", flexFlow: "column nowrap", alignItems: "center" }, text: { textAlign: "center", padding: `0 ${theme.spacing(2.5)}px`, fontWeight: 400, fontSize: "1.125rem", color: theme.palette.text.secondary, lineHeight: 1.1 }, linkSpacer: { paddingTop: theme.spacing(1.5) }, // Styled as a button to be more apparent to sighted users, but exposed as a // link for better right-click/middle-click support and screen reader support callToActionLink: { fontWeight: 500, color: theme.palette.primary.contrastText, backgroundColor: theme.palette.primary.main, padding: `${theme.spacing(1)}px ${theme.spacing(1.5)}px`, borderRadius: theme.shape.borderRadius, boxShadow: theme.shadows[1], "&:hover": { backgroundColor: theme.palette.primary.dark, boxShadow: theme.shadows[2] } } })); const Placeholder = ({ children, displayCta = false }) => { const styles = usePlaceholderStyles(); const { workspacesConfig } = useWorkspacesCardContext(); return /* @__PURE__ */ React.createElement("div", { className: styles.root }, /* @__PURE__ */ React.createElement(CoderLogo, null), /* @__PURE__ */ React.createElement("p", { className: styles.text }, children), displayCta && /* @__PURE__ */ React.createElement("div", { className: styles.linkSpacer }, /* @__PURE__ */ React.createElement( "a", { href: workspacesConfig.creationUrl, target: "_blank", className: styles.callToActionLink }, "Create workspace", /* @__PURE__ */ React.createElement(VisuallyHidden, null, " (Link opens in new tab)") ))); }; const useWorkspacesListStyles = makeStyles((theme) => ({ root: ({ fullBleedLayout }) => ({ maxHeight: "260px", overflowX: "hidden", overflowY: "auto", flexShrink: 1, borderTop: `1px solid ${theme.palette.divider}`, marginLeft: fullBleedLayout ? `-${theme.spacing(2)}px` : 0, marginRight: fullBleedLayout ? `-${theme.spacing(2)}px` : 0, // Negative bottom margin is to ensure that the overflow bar doesn't look // weird when it kicks in; should figure out a way to implement this with // padding instead to prevent CSS styling side effects marginBottom: `-${theme.spacing(2)}px` }), list: { margin: 0, paddingRight: theme.spacing(2), paddingBottom: theme.spacing(2), // Not using spacing(2) for optical adjustment reasons; want to make sure // all workspace icons are aligned with the search bar icon by default paddingLeft: theme.spacing(1.75) }, code: { display: "block", paddingTop: theme.spacing(0.75), fontSize: "87.5%", color: theme.palette.text.primary } })); const WorkspacesList = ({ renderListItem, emptyState, className, listClassName, ordered = true, fullBleedLayout = true, ...delegatedProps }) => { var _a, _b, _c; const { workspacesQuery, workspacesConfig } = useWorkspacesCardContext(); const styles = useWorkspacesListStyles({ fullBleedLayout }); const repoUrl = (_a = workspacesConfig.repoUrl) != null ? _a : ""; const ListItemContainer = ordered ? "ol" : "ul"; return /* @__PURE__ */ React.createElement("div", { className: `${styles.root} ${className != null ? className : ""}`, ...delegatedProps }, workspacesQuery.isLoading && /* @__PURE__ */ React.createElement(Placeholder, null, workspacesQuery.fetchStatus === "fetching" ? /* @__PURE__ */ React.createElement(React.Fragment, null, "Loading\u2026") : /* @__PURE__ */ React.createElement(React.Fragment, null, "Use the search bar to find matching Coder workspaces")), ((_b = workspacesQuery.data) == null ? void 0 : _b.length) === 0 && /* @__PURE__ */ React.createElement(React.Fragment, null, emptyState != null ? emptyState : /* @__PURE__ */ React.createElement(Placeholder, { displayCta: Boolean(repoUrl) }, repoUrl ? /* @__PURE__ */ React.createElement("span", { style: { display: "block", textAlign: "center" } }, "No workspaces found for repo", /* @__PURE__ */ React.createElement("code", { className: styles.code }, repoUrl)) : /* @__PURE__ */ React.createElement(React.Fragment, null, "No workspaces returned for your query"))), workspacesQuery.data && workspacesQuery.data.length > 0 && /* @__PURE__ */ React.createElement(ListItemContainer, { className: `${styles.list} ${listClassName != null ? listClassName : ""}` }, (_c = workspacesQuery.data) == null ? void 0 : _c.map((workspace, index) => /* @__PURE__ */ React.createElement(Fragment, { key: workspace.id }, renderListItem !== void 0 ? renderListItem({ workspace, index, workspaces: workspacesQuery.data }) : /* @__PURE__ */ React.createElement(WorkspacesListItem, { workspace }))))); }; const useStyles$5 = makeStyles((theme) => { const padding = theme.spacing(0.5); return { root: ({ canCreateWorkspace }) => ({ padding, width: theme.spacing(4) + padding, height: theme.spacing(4) + padding, cursor: "pointer", display: "flex", justifyContent: "center", alignItems: "center", backgroundColor: "inherit", borderRadius: "9999px", lineHeight: 1, color: canCreateWorkspace ? theme.palette.text.primary : theme.palette.text.disabled, "&:hover": { backgroundColor: canCreateWorkspace ? theme.palette.action.hover : "inherit" } }), noLinkTooltipContainer: { display: "block", maxWidth: "24em" } }; }); const CreateWorkspaceLink = ({ children, className, tooltipRef, target = "_blank", tooltipText = "Add a new workspace", tooltipProps = {}, ...delegatedProps }) => { const { workspacesConfig } = useWorkspacesCardContext(); const canCreateWorkspace = Boolean(workspacesConfig.creationUrl); const styles = useStyles$5({ canCreateWorkspace }); return /* @__PURE__ */ React.createElement( Tooltip, { ref: tooltipRef, title: canCreateWorkspace ? tooltipText : /* @__PURE__ */ React.createElement("span", { className: styles.noLinkTooltipContainer }, "Please add a template name value. More info available in the accordion at the bottom of this widget."), ...tooltipProps }, /* @__PURE__ */ React.createElement( "a", { role: "link", target, className: `${styles.root} ${className != null ? className : ""}`, href: workspacesConfig.creationUrl, "aria-disabled": !canCreateWorkspace, ...delegatedProps }, children != null ? children : /* @__PURE__ */ React.createElement(AddIcon, null), /* @__PURE__ */ React.createElement(VisuallyHidden, null, canCreateWorkspace ? /* @__PURE__ */ React.createElement(React.Fragment, null, tooltipText, target === "_blank" && /* @__PURE__ */ React.createElement(React.Fragment, null, " (Link opens in new tab)")) : /* @__PURE__ */ React.createElement(React.Fragment, null, "This component does not have a usable template name. Please see the disclosure section in this widget for steps on adding this information.")) ) ); }; const REFRESH_THROTTLE_MS = 1e3; const useStyles$4 = makeStyles((theme) => { const padding = theme.spacing(0.5); return { root: { padding, margin: 0, display: "flex", justifyContent: "center", alignItems: "center", color: theme.palette.text.primary, width: theme.spacing(4) + padding, height: theme.spacing(4) + padding, border: "none", borderRadius: "9999px", backgroundColor: "inherit", lineHeight: 1, // Buttons don't traditionally have the pointer style, but it's being // changed to match the cursor style for CreateWorkspaceButtonLink cursor: "pointer", "&:hover": { backgroundColor: theme.palette.action.hover } }, menuList: { "& > li:first-child:focus": { backgroundColor: theme.palette.action.hover } } }; }); const ExtraActionsButton = ({ menuProps, buttonRef, toolTipProps, tooltipRef, children, className, onClick: outerOnClick, onClose: outerOnClose, tooltipText = "See additional workspace actions", ...delegatedButtonProps }) => { const { className: menuListClassName, ref: menuListRef, MenuListProps = {}, ...delegatedMenuProps } = menuProps != null ? menuProps : {}; const hookId = useId(); const [loadedAnchor, setLoadedAnchor] = useState(); const refreshWorkspaces = useRefreshWorkspaces(); const { unlinkToken } = useInternalCoderAuth(); const styles = useStyles$4(); const closeMenu = () => setLoadedAnchor(void 0); const isOpen = loadedAnchor !== void 0; const menuId = `${hookId}-menu`; const buttonId = `${hookId}-button`; const keyboardInstructionsId = `${hookId}-instructions`; return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Tooltip, { ref: tooltipRef, title: tooltipText, ...toolTipProps }, /* @__PURE__ */ React.createElement( "button", { ref: buttonRef, id: buttonId, "aria-controls": isOpen ? menuId : void 0, className: `${styles.root} ${className != null ? className : ""}`, type: "button", onClick: (event) => { setLoadedAnchor(event.currentTarget); outerOnClick == null ? void 0 : outerOnClick(event); }, ...delegatedButtonProps }, children != null ? children : /* @__PURE__ */ React.createElement(MoreItemsIcon, null), /* @__PURE__ */ React.createElement(VisuallyHidden, null, tooltipText) )), /* @__PURE__ */ React.createElement("p", { hidden: true, id: keyboardInstructionsId }, "Press the up and down arrow keys to navigate between list items. Press Escape to close the menu."), /* @__PURE__ */ React.createElement( Menu, { getContentAnchorEl: null, anchorOrigin: { horizontal: "center", vertical: "bottom" }, id: menuId, open: isOpen, anchorEl: loadedAnchor, MenuListProps: { variant: "menu", autoFocusItem: true, dense: true, "aria-labelledby": buttonId, "aria-describedby": keyboardInstructionsId, className: `${styles.menuList} ${menuListClassName != null ? menuListClassName : ""}`, ...MenuListProps }, onClose: (event, reason) => { closeMenu(); outerOnClose == null ? void 0 : outerOnClose(event, reason); }, ...delegatedMenuProps }, /* @__PURE__ */ React.createElement( MenuItem, { onClick: () => { refreshWorkspaces(); closeMenu(); } }, "Refresh" ), /* @__PURE__ */ React.createElement( MenuItem, { onClick: () => { unlinkToken(); closeMenu(); } }, "Unlink Coder account" ) )); }; function useRefreshWorkspaces() { const { workspacesQuery } = useWorkspacesCardContext(); const refreshThrottleIdRef = useRef(); useEffect(() => { const clearThrottleOnUnmount = () => { window.clearTimeout(refreshThrottleIdRef.current); }; return clearThrottleOnUnmount; }, []); const refreshWorkspaces = () => { if (refreshThrottleIdRef.current !== void 0) { return; } workspacesQuery.refetch(); refreshThrottleIdRef.current = window.setTimeout(() => { refreshThrottleIdRef.current = void 0; }, REFRESH_THROTTLE_MS); }; return refreshWorkspaces; } const useStyles$3 = makeStyles((theme) => ({ disclosureTriangle: { display: "inline-block", textAlign: "right", width: theme.spacing(2.25), fontSize: "0.7rem" }, disclosureBody: { margin: 0, padding: `${theme.spacing(0.5)}px ${theme.spacing(3.5)}px 0 ${theme.spacing( 4 )}px` }, button: { width: "100%", textAlign: "left", color: theme.palette.text.primary, backgroundColor: theme.palette.background.paper, padding: theme.spacing(1), border: "none", borderRadius: theme.shape.borderRadius, fontSize: theme.typography.body2.fontSize, cursor: "pointer", "&:hover": { backgroundColor: theme.palette.action.hover }, "&:not(:first-child)": { paddingTop: theme.spacing(6) } } })); const Disclosure = ({ isExpanded, onExpansionToggle, headerText, children, ...delegatedProps }) => { const hookId = useId(); const styles = useStyles$3(); const [internalIsExpanded, setInternalIsExpanded] = useState( isExpanded != null ? isExpanded : false ); const activeIsExpanded = isExpanded != null ? isExpanded : internalIsExpanded; const disclosureBodyId = `${hookId}-disclosure-body`; return /* @__PURE__ */ React.createElement("div", { ...delegatedProps }, /* @__PURE__ */ React.createElement( "button", { type: "button", "aria-expanded": activeIsExpanded, "aria-controls": disclosureBodyId, className: styles.button, onClick: () => { setInternalIsExpanded(!internalIsExpanded); onExpansionToggle == null ? void 0 : onExpansionToggle(); } }, /* @__PURE__ */ React.createElement("span", { "aria-hidden": true, className: styles.disclosureTriangle }, activeIsExpanded ? "\u25BC" : "\u25BA"), " ", headerText ), activeIsExpanded && /* @__PURE__ */ React.createElement("p", { id: disclosureBodyId, className: styles.disclosureBody }, children)); }; const useStyles$2 = makeStyles((theme) => ({ root: { fontSize: theme.typography.body2.fontSize, color: theme.palette.text.primary, borderRadius: theme.spacing(0.5), padding: `${theme.spacing(0.2)}px ${theme.spacing(1)}px`, backgroundColor: () => { const isLightTheme = theme.palette.type === "light"; return isLightTheme ? "hsl(0deg,0%,93%)" : theme.palette.background.default; } } })); function InlineCodeSnippet({ children, ...delegatedProps }) { const styles = useStyles$2(); return /* @__PURE__ */ React.createElement("code", { className: styles.root, ...delegatedProps }, children); } const useStyles$1 = makeStyles((theme) => ({ root: ({ hasData }) => ({ paddingTop: theme.spacing(1), marginLeft: `-${theme.spacing(2)}px`, marginRight: `-${theme.spacing(2)}px`, marginBottom: `-${theme.spacing(2)}px`, borderTop: hasData ? "none" : `1px solid ${theme.palette.divider}`, maxHeight: "240px", overflowX: "hidden", overflowY: "auto" }), innerPadding: { paddingLeft: theme.spacing(2), paddingRight: theme.spacing(2), paddingBottom: theme.spacing(2) }, link: { color: theme.palette.link, "&:hover": { textDecoration: "underline" } }, disclosure: { "&:not(:first-child)": { paddingTop: theme.spacing(1) } } })); function ReminderAccordion({ canShowEntityReminder = true, canShowTemplateNameReminder = true }) { const [activeItemId, setActiveItemId] = useState(); const { workspacesConfig, workspacesQuery } = useWorkspacesCardContext(); const styles = useStyles$1({ hasData: workspacesQuery.data !== void 0 }); const accordionData = [ { id: "entity", canDisplay: canShowEntityReminder && workspacesConfig.isReadingEntityData && !workspacesConfig.repoUrl, headerText: "Why am I not seeing any workspaces?", bodyText: /* @__PURE__ */ React.createElement(React.Fragment, null, "This component only displays all workspaces when the value of the", " ", /* @__PURE__ */ React.createElement(InlineCodeSnippet, null, "readEntityData"), " prop is ", /* @__PURE__ */ React.createElement(InlineCodeSnippet, null, "false"), ". See", " ", /* @__PURE__ */ React.createElement( "a", { href: "https://github.com/coder/backstage-plugins/blob/main/plugins/backstage-plugin-coder/docs/components.md#notes-4", rel: "noopener noreferrer", target: "_blank", className: styles.link }, "our documentation", /* @__PURE__ */ React.createElement(VisuallyHidden, null, " (link opens in new tab)") ), " ", "for more info.") }, { id: "templateName", canDisplay: canShowTemplateNameReminder && !workspacesConfig.creationUrl, headerText: /* @__PURE__ */ React.createElement(React.Fragment, null, "Why can't I make a new workspace?"), bodyText: /* @__PURE__ */ React.createElement(React.Fragment, null, "This component cannot make a new workspace without a template name value. Values can be provided via", " ", /* @__PURE__ */ React.createElement(InlineCodeSnippet, null, "defaultTemplateName"), " in", " ", /* @__PURE__ */ React.createElement(InlineCodeSnippet, null, "CoderAppConfig"), " or the", " ", /* @__PURE__ */ React.createElement(InlineCodeSnippet, null, "templateName"), " property in a repo's", " ", /* @__PURE__ */ React.createElement(InlineCodeSnippet, null, "catalog-info.yaml"), " file. See", " ", /* @__PURE__ */ React.createElement( "a", { href: "https://github.com/coder/backstage-plugins/blob/main/plugins/backstage-plugin-coder/docs/components.md#coderappconfig", rel: "noopener noreferrer", target: "_blank", className: styles.link }, "our documentation", /* @__PURE__ */ React.createElement(VisuallyHidden, null, " (link opens in new tab)") ), " ", "for more info.") } ]; const toggleAccordionGroup = (newItemId) => { if (newItemId === activeItemId) { setActiveItemId(void 0); } else { setActiveItemId(newItemId); } }; return /* @__PURE__ */ React.createElement("div", { role: "group", className: styles.root }, /* @__PURE__ */ React.createElement("div", { className: styles.innerPadding }, accordionData.map(({ id, canDisplay, headerText, bodyText }) => /* @__PURE__ */ React.createElement(Fragment, { key: id }, canDisplay && /* @__PURE__ */ React.createElement( Disclosure, { className: styles.disclosure, headerText, isExpanded: id === activeItemId, onExpansionToggle: () => toggleAccordionGroup(id) }, bodyText ))))); } const useStyles = makeStyles((theme) => ({ searchWrapper: { paddingTop: theme.spacing(1.5), paddingBottom: theme.spacing(1.5) } })); const CoderWorkspacesCard = (props) => { const styles = useStyles(); return /* @__PURE__ */ React.createElement( Root, { headerContent: /* @__PURE__ */ React.createElement( HeaderRow, { headerLevel: "h2", actions: /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(CreateWorkspaceLink, null), /* @__PURE__ */ React.createElement(ExtraActionsButton, null)) } ), ...props }, /* @__PURE__ */ React.createElement("div", { className: styles.searchWrapper }, /* @__PURE__ */ React.createElement(SearchBox, null)), /* @__PURE__ */ React.createElement(WorkspacesList, null), /* @__PURE__ */ React.createElement(ReminderAccordion, null) ); }; export { CoderWorkspacesCard, CreateWorkspaceLink, ExtraActionsButton, HeaderRow, ReminderAccordion, SearchBox, WorkspacesList, WorkspacesListIcon, WorkspacesListItem }; //# sourceMappingURL=index-db2e21de.esm.js.map