@coder/backstage-plugin-coder
Version:
Create and manage Coder workspaces from Backstage
1,032 lines (1,016 loc) • 37.5 kB
JavaScript
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