mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-22 08:51:41 +00:00
client/web: add confirmation dialogs
Add confirmation dialogs for disconnecting and stopping advertisement of a subnet route. Updates #10261 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
parent
69b56462fc
commit
a4c7b0574a
@ -9,6 +9,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-popover": "^1.0.6",
|
"@radix-ui/react-popover": "^1.0.6",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
@ -73,6 +73,11 @@ function WebClient({
|
|||||||
currentVersion={node.IPNVersion}
|
currentVersion={node.IPNVersion}
|
||||||
/>
|
/>
|
||||||
</FeatureRoute>
|
</FeatureRoute>
|
||||||
|
<Route path="/disconnected">
|
||||||
|
<Card className="mt-8">
|
||||||
|
<EmptyState description="You have been disconnected" />
|
||||||
|
</Card>
|
||||||
|
</Route>
|
||||||
<Route>
|
<Route>
|
||||||
<Card className="mt-8">
|
<Card className="mt-8">
|
||||||
<EmptyState description="Page not found" />
|
<EmptyState description="Page not found" />
|
||||||
@ -129,6 +134,11 @@ function Header({
|
|||||||
}) {
|
}) {
|
||||||
const [loc] = useLocation()
|
const [loc] = useLocation()
|
||||||
|
|
||||||
|
if (loc === "/disconnected") {
|
||||||
|
// No header on view presented after logout.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap gap-4 justify-between items-center mb-9 md:mb-12">
|
<div className="flex flex-wrap gap-4 justify-between items-center mb-9 md:mb-12">
|
||||||
|
@ -11,6 +11,7 @@ import { UpdateAvailableNotification } from "src/components/update-available"
|
|||||||
import { NodeData } from "src/types"
|
import { NodeData } from "src/types"
|
||||||
import Button from "src/ui/button"
|
import Button from "src/ui/button"
|
||||||
import Card from "src/ui/card"
|
import Card from "src/ui/card"
|
||||||
|
import Dialog from "src/ui/dialog"
|
||||||
import QuickCopy from "src/ui/quick-copy"
|
import QuickCopy from "src/ui/quick-copy"
|
||||||
import { useLocation } from "wouter"
|
import { useLocation } from "wouter"
|
||||||
|
|
||||||
@ -21,9 +22,6 @@ export default function DeviceDetailsView({
|
|||||||
readonly: boolean
|
readonly: boolean
|
||||||
node: NodeData
|
node: NodeData
|
||||||
}) {
|
}) {
|
||||||
const api = useAPI()
|
|
||||||
const [, setLocation] = useLocation()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="mb-10">Device details</h1>
|
<h1 className="mb-10">Device details</h1>
|
||||||
@ -39,16 +37,7 @@ export default function DeviceDetailsView({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!readonly && (
|
{!readonly && <DisconnectDialog />}
|
||||||
<Button
|
|
||||||
sizeVariant="small"
|
|
||||||
onClick={() =>
|
|
||||||
api({ action: "logout" }).then(() => setLocation("/"))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Disconnect…
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
{node.Features["auto-update"] &&
|
{node.Features["auto-update"] &&
|
||||||
@ -210,3 +199,33 @@ export default function DeviceDetailsView({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DisconnectDialog() {
|
||||||
|
const api = useAPI()
|
||||||
|
const [, setLocation] = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
className="max-w-md"
|
||||||
|
title="Disconnect"
|
||||||
|
trigger={<Button sizeVariant="small">Disconnect…</Button>}
|
||||||
|
>
|
||||||
|
<Dialog.Form
|
||||||
|
cancelButton
|
||||||
|
submitButton="Disconnect"
|
||||||
|
destructive
|
||||||
|
onSubmit={() => {
|
||||||
|
api({ action: "logout" })
|
||||||
|
setLocation("/disconnected")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
You are about to disconnect this device from your tailnet. To reconnect,
|
||||||
|
you will be required to re-authenticate this device.
|
||||||
|
<p className="mt-4 text-sm text-text-muted">
|
||||||
|
Your connection to this web interface will end as soon as you click
|
||||||
|
disconnect.
|
||||||
|
</p>
|
||||||
|
</Dialog.Form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -11,6 +11,7 @@ import * as Control from "src/components/control-components"
|
|||||||
import { NodeData } from "src/types"
|
import { NodeData } from "src/types"
|
||||||
import Button from "src/ui/button"
|
import Button from "src/ui/button"
|
||||||
import Card from "src/ui/card"
|
import Card from "src/ui/card"
|
||||||
|
import Dialog from "src/ui/dialog"
|
||||||
import EmptyState from "src/ui/empty-state"
|
import EmptyState from "src/ui/empty-state"
|
||||||
import Input from "src/ui/input"
|
import Input from "src/ui/input"
|
||||||
|
|
||||||
@ -139,9 +140,8 @@ export default function SubnetRouterView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<Button
|
<StopAdvertisingDialog
|
||||||
sizeVariant="small"
|
onSubmit={() =>
|
||||||
onClick={() =>
|
|
||||||
api({
|
api({
|
||||||
action: "update-routes",
|
action: "update-routes",
|
||||||
data: advertisedRoutes.filter(
|
data: advertisedRoutes.filter(
|
||||||
@ -149,9 +149,7 @@ export default function SubnetRouterView({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
Stop advertising…
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -179,3 +177,22 @@ export default function SubnetRouterView({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StopAdvertisingDialog({ onSubmit }: { onSubmit: () => void }) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
className="max-w-md"
|
||||||
|
title="Stop advertising route"
|
||||||
|
trigger={<Button sizeVariant="small">Stop advertising…</Button>}
|
||||||
|
>
|
||||||
|
<Dialog.Form
|
||||||
|
cancelButton
|
||||||
|
submitButton="Stop advertising"
|
||||||
|
destructive
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
|
Any active connections between devices over this route will be broken.
|
||||||
|
</Dialog.Form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
370
client/web/src/ui/dialog.tsx
Normal file
370
client/web/src/ui/dialog.tsx
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import cx from "classnames"
|
||||||
|
import React, { Component, ComponentProps, FormEvent } from "react"
|
||||||
|
import { ReactComponent as X } from "src/assets/icons/x.svg"
|
||||||
|
import Button from "src/ui/button"
|
||||||
|
import PortalContainerContext from "src/ui/portal-container-context"
|
||||||
|
import { isObject } from "src/utils/util"
|
||||||
|
|
||||||
|
type ButtonProp = boolean | string | Partial<ComponentProps<typeof Button>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ControlledDialogProps are common props required for dialog components with
|
||||||
|
* controlled state. Since Dialog components frequently expose these props to
|
||||||
|
* their callers, we've consolidated them here for easy access.
|
||||||
|
*/
|
||||||
|
export type ControlledDialogProps = {
|
||||||
|
/**
|
||||||
|
* open is a boolean that controls whether the dialog is open or not.
|
||||||
|
*/
|
||||||
|
open: boolean
|
||||||
|
/**
|
||||||
|
* onOpenChange is a callback that is called when the open state of the dialog
|
||||||
|
* changes.
|
||||||
|
*/
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PointerDownOutsideEvent = CustomEvent<{
|
||||||
|
originalEvent: PointerEvent
|
||||||
|
}>
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string
|
||||||
|
/**
|
||||||
|
* title is the title of the dialog, shown at the top.
|
||||||
|
*/
|
||||||
|
title: string
|
||||||
|
/**
|
||||||
|
* titleSuffixDecoration is added to the title, but is not part of the ARIA label for
|
||||||
|
* the dialog. This is useful for adding a badge or other non-semantic
|
||||||
|
* information to the title.
|
||||||
|
*/
|
||||||
|
titleSuffixDecoration?: React.ReactNode
|
||||||
|
/**
|
||||||
|
* trigger is an element to use as a trigger for a dialog. Using trigger is
|
||||||
|
* preferrable to using `open` for managing state, as it allows for better
|
||||||
|
* focus management for screen readers.
|
||||||
|
*/
|
||||||
|
trigger?: React.ReactNode
|
||||||
|
/**
|
||||||
|
* children is the content of the dialog.
|
||||||
|
*/
|
||||||
|
children: React.ReactNode
|
||||||
|
/**
|
||||||
|
* defaultOpen is the default state of the dialog. This is meant to be used for
|
||||||
|
* uncontrolled dialogs, and should not be combined with `open` or
|
||||||
|
* `onOpenChange`.
|
||||||
|
*/
|
||||||
|
defaultOpen?: boolean
|
||||||
|
/**
|
||||||
|
* restoreFocus determines whether the dialog returns focus to the trigger
|
||||||
|
* element or not after closing.
|
||||||
|
*/
|
||||||
|
restoreFocus?: boolean
|
||||||
|
onPointerDownOutside?: (e: PointerDownOutsideEvent) => void
|
||||||
|
} & Partial<ControlledDialogProps>
|
||||||
|
|
||||||
|
const dialogOverlay =
|
||||||
|
"fixed overflow-y-auto inset-0 py-8 z-10 bg-gray-900 bg-opacity-[0.07]"
|
||||||
|
const dialogWindow = cx(
|
||||||
|
"bg-white rounded-lg relative max-w-lg min-w-[19rem] w-[97%] shadow-dialog",
|
||||||
|
"p-4 md:p-6 my-8 mx-auto",
|
||||||
|
// We use `transform-gpu` here to force the browser to put the dialog on its
|
||||||
|
// own layer. This helps fix some weird artifacting bugs in Safari caused by
|
||||||
|
// box-shadows. See: https://github.com/tailscale/corp/issues/12270
|
||||||
|
"transform-gpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog provides a modal dialog, for prompting a user for input or confirmation
|
||||||
|
* before proceeding.
|
||||||
|
*/
|
||||||
|
export default function Dialog(props: Props) {
|
||||||
|
const {
|
||||||
|
open,
|
||||||
|
className,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
trigger,
|
||||||
|
title,
|
||||||
|
titleSuffixDecoration,
|
||||||
|
children,
|
||||||
|
restoreFocus = true,
|
||||||
|
onPointerDownOutside,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Root
|
||||||
|
open={open}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
>
|
||||||
|
{trigger && (
|
||||||
|
<DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>
|
||||||
|
)}
|
||||||
|
<PortalContainerContext.Consumer>
|
||||||
|
{(portalContainer) => (
|
||||||
|
<DialogPrimitive.Portal container={portalContainer}>
|
||||||
|
<DialogPrimitive.Overlay className={dialogOverlay}>
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
aria-label={title}
|
||||||
|
className={cx(dialogWindow, className)}
|
||||||
|
onCloseAutoFocus={
|
||||||
|
// Cancel the focus restore if `restoreFocus` is set to false
|
||||||
|
restoreFocus === false ? (e) => e.preventDefault() : undefined
|
||||||
|
}
|
||||||
|
onPointerDownOutside={onPointerDownOutside}
|
||||||
|
>
|
||||||
|
<DialogErrorBoundary>
|
||||||
|
<header className="flex items-center justify-between space-x-4 mb-5 mr-8">
|
||||||
|
<div className="font-semibold text-lg truncate">
|
||||||
|
{title}
|
||||||
|
{titleSuffixDecoration}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button
|
||||||
|
variant="minimal"
|
||||||
|
className="absolute top-5 right-5 px-2 py-2"
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
aria-hidden
|
||||||
|
className="h-[1.25em] w-[1.25em] stroke-current"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogErrorBoundary>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Overlay>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
)}
|
||||||
|
</PortalContainerContext.Consumer>
|
||||||
|
</DialogPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog.Form is a standard way of providing form-based interactions in a
|
||||||
|
* Dialog component. Prefer it to custom form implementations. See each props
|
||||||
|
* documentation for details.
|
||||||
|
*
|
||||||
|
* <Dialog.Form cancelButton submitButton="Save" onSubmit={saveThing}>
|
||||||
|
* <input type="text" value={myValue} onChange={myChangeHandler} />
|
||||||
|
* </Dialog.Form>
|
||||||
|
*/
|
||||||
|
Dialog.Form = DialogForm
|
||||||
|
|
||||||
|
type FormProps = {
|
||||||
|
/**
|
||||||
|
* destructive declares whether the submit button should be styled as a danger
|
||||||
|
* button or not. Prefer `destructive` over passing a props object to
|
||||||
|
* `submitButton`, since objects cause unnecessary re-renders unless they are
|
||||||
|
* moved outside the render function.
|
||||||
|
*/
|
||||||
|
destructive?: boolean
|
||||||
|
/**
|
||||||
|
* children is the content of the dialog form.
|
||||||
|
*/
|
||||||
|
children?: React.ReactNode
|
||||||
|
/**
|
||||||
|
* disabled determines whether the submit button should be disabled. The
|
||||||
|
* cancel button cannot be disabled via this prop.
|
||||||
|
*/
|
||||||
|
disabled?: boolean
|
||||||
|
/**
|
||||||
|
* loading determines whether the submit button should display a loading state
|
||||||
|
* and the cancel button should be disabled.
|
||||||
|
*/
|
||||||
|
loading?: boolean
|
||||||
|
/**
|
||||||
|
* cancelButton determines how the cancel button looks. You can pass `true`,
|
||||||
|
* which adds a default button, pass a string which changes the button label,
|
||||||
|
* or pass an object, which is a set of props to pass to a `Button` component.
|
||||||
|
* Any unspecified props will fall back to default values.
|
||||||
|
*
|
||||||
|
* <Dialog.Form cancelButton />
|
||||||
|
* <Dialog.Form cancelButton="Done" />
|
||||||
|
* <Dialog.Form cancelButton={{ children: "Back", variant: "primary" }} />
|
||||||
|
*/
|
||||||
|
cancelButton?: ButtonProp
|
||||||
|
/**
|
||||||
|
* submitButton determines how the submit button looks. You can pass `true`,
|
||||||
|
* which adds a default button, pass a string which changes the button label,
|
||||||
|
* or pass an object, which is a set of props to pass to a `Button` component.
|
||||||
|
* Any unspecified props will fall back to default values.
|
||||||
|
*
|
||||||
|
* <Dialog.Form submitButton />
|
||||||
|
* <Dialog.Form submitButton="Save" />
|
||||||
|
* <Dialog.Form submitButton="Delete" destructive />
|
||||||
|
* <Dialog.Form submitButton={{ children: "Banana", className: "bg-yellow-500" }} />
|
||||||
|
*/
|
||||||
|
submitButton?: ButtonProp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onSubmit is the callback to use when the form is submitted. Using `onSubmit`
|
||||||
|
* is preferrable to a `onClick` handler on `submitButton`, which doesn't get
|
||||||
|
* triggered on keyboard events.
|
||||||
|
*/
|
||||||
|
onSubmit?: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* autoFocus makes it easy to focus a particular action button without
|
||||||
|
* overriding the button props.
|
||||||
|
*/
|
||||||
|
autoFocus?: "submit" | "cancel"
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogForm(props: FormProps) {
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
destructive = false,
|
||||||
|
loading = false,
|
||||||
|
autoFocus = "submit",
|
||||||
|
cancelButton,
|
||||||
|
submitButton,
|
||||||
|
onSubmit,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const hasFooter = Boolean(cancelButton || submitButton)
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelAutoFocus = Boolean(
|
||||||
|
cancelButton && !loading && autoFocus === "cancel"
|
||||||
|
)
|
||||||
|
const submitAutoFocus = Boolean(
|
||||||
|
submitButton && !loading && !disabled && autoFocus === "submit"
|
||||||
|
)
|
||||||
|
const submitIntent = destructive ? "danger" : "primary"
|
||||||
|
|
||||||
|
let cancelButtonEl = null
|
||||||
|
|
||||||
|
if (cancelButton) {
|
||||||
|
cancelButtonEl =
|
||||||
|
cancelButton === true ? (
|
||||||
|
<Button
|
||||||
|
{...cancelButtonDefaultProps}
|
||||||
|
autoFocus={cancelAutoFocus}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
) : typeof cancelButton === "string" ? (
|
||||||
|
<Button
|
||||||
|
{...cancelButtonDefaultProps}
|
||||||
|
autoFocus={cancelAutoFocus}
|
||||||
|
children={cancelButton}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
{...cancelButtonDefaultProps}
|
||||||
|
autoFocus={cancelAutoFocus}
|
||||||
|
disabled={loading}
|
||||||
|
{...cancelButton}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasCustomCancelAction =
|
||||||
|
isObject(cancelButton) && cancelButton.onClick !== undefined
|
||||||
|
if (!hasCustomCancelAction) {
|
||||||
|
cancelButtonEl = (
|
||||||
|
<DialogPrimitive.Close asChild>{cancelButtonEl}</DialogPrimitive.Close>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{children}
|
||||||
|
{hasFooter && (
|
||||||
|
<footer className="flex mt-10 justify-end space-x-4">
|
||||||
|
{cancelButtonEl}
|
||||||
|
{submitButton && (
|
||||||
|
<>
|
||||||
|
{submitButton === true ? (
|
||||||
|
<Button
|
||||||
|
{...submitButtonDefaultProps}
|
||||||
|
intent={submitIntent}
|
||||||
|
autoFocus={submitAutoFocus}
|
||||||
|
disabled={loading || disabled}
|
||||||
|
/>
|
||||||
|
) : typeof submitButton === "string" ? (
|
||||||
|
<Button
|
||||||
|
{...submitButtonDefaultProps}
|
||||||
|
intent={submitIntent}
|
||||||
|
children={submitButton}
|
||||||
|
autoFocus={submitAutoFocus}
|
||||||
|
disabled={loading || disabled}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
{...submitButtonDefaultProps}
|
||||||
|
intent={submitIntent}
|
||||||
|
autoFocus={submitAutoFocus}
|
||||||
|
disabled={loading || disabled}
|
||||||
|
{...submitButton}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelButtonDefaultProps: Pick<
|
||||||
|
ComponentProps<typeof Button>,
|
||||||
|
"type" | "intent" | "sizeVariant" | "children"
|
||||||
|
> = {
|
||||||
|
type: "button",
|
||||||
|
intent: "base",
|
||||||
|
sizeVariant: "medium",
|
||||||
|
children: "Cancel",
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitButtonDefaultProps: Pick<
|
||||||
|
ComponentProps<typeof Button>,
|
||||||
|
"type" | "sizeVariant" | "children" | "autoFocus"
|
||||||
|
> = {
|
||||||
|
type: "submit",
|
||||||
|
sizeVariant: "medium",
|
||||||
|
children: "Submit",
|
||||||
|
}
|
||||||
|
|
||||||
|
type DialogErrorBoundaryProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
class DialogErrorBoundary extends Component<
|
||||||
|
DialogErrorBoundaryProps,
|
||||||
|
{ hasError: boolean }
|
||||||
|
> {
|
||||||
|
constructor(props: DialogErrorBoundaryProps) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { hasError: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.log(error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return <div className="font-semibold text-lg">Something went wrong.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,7 @@
|
|||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import React, { ReactNode } from "react"
|
import React, { ReactNode } from "react"
|
||||||
|
import PortalContainerContext from "src/ui/portal-container-context"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string
|
className?: string
|
||||||
@ -103,7 +104,3 @@ export default function Popover(props: Props) {
|
|||||||
Popover.defaultProps = {
|
Popover.defaultProps = {
|
||||||
sideOffset: 10,
|
sideOffset: 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
const PortalContainerContext = React.createContext<HTMLElement | undefined>(
|
|
||||||
undefined
|
|
||||||
)
|
|
||||||
|
9
client/web/src/ui/portal-container-context.tsx
Normal file
9
client/web/src/ui/portal-container-context.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
const PortalContainerContext = React.createContext<HTMLElement | undefined>(
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
export default PortalContainerContext
|
@ -14,6 +14,13 @@ export function assertNever(a: never): never {
|
|||||||
*/
|
*/
|
||||||
export function noop() {}
|
export function noop() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isObject checks if a value is an object.
|
||||||
|
*/
|
||||||
|
export function isObject(val: unknown): val is object {
|
||||||
|
return Boolean(val && typeof val === "object" && val.constructor === Object)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* pluralize is a very simple function that returns either
|
* pluralize is a very simple function that returns either
|
||||||
* the singular or plural form of a string based on the given
|
* the singular or plural form of a string based on the given
|
||||||
|
@ -1629,6 +1629,27 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.13.10"
|
"@babel/runtime" "^7.13.10"
|
||||||
|
|
||||||
|
"@radix-ui/react-dialog@^1.0.5":
|
||||||
|
version "1.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300"
|
||||||
|
integrity sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.13.10"
|
||||||
|
"@radix-ui/primitive" "1.0.1"
|
||||||
|
"@radix-ui/react-compose-refs" "1.0.1"
|
||||||
|
"@radix-ui/react-context" "1.0.1"
|
||||||
|
"@radix-ui/react-dismissable-layer" "1.0.5"
|
||||||
|
"@radix-ui/react-focus-guards" "1.0.1"
|
||||||
|
"@radix-ui/react-focus-scope" "1.0.4"
|
||||||
|
"@radix-ui/react-id" "1.0.1"
|
||||||
|
"@radix-ui/react-portal" "1.0.4"
|
||||||
|
"@radix-ui/react-presence" "1.0.1"
|
||||||
|
"@radix-ui/react-primitive" "1.0.3"
|
||||||
|
"@radix-ui/react-slot" "1.0.2"
|
||||||
|
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||||
|
aria-hidden "^1.1.1"
|
||||||
|
react-remove-scroll "2.5.5"
|
||||||
|
|
||||||
"@radix-ui/react-dismissable-layer@1.0.5":
|
"@radix-ui/react-dismissable-layer@1.0.5":
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user