Better environment edit dialog

This commit is contained in:
Gregory Schier
2024-02-18 07:44:53 -08:00
parent e934ca9586
commit ed55eb2238
10 changed files with 171 additions and 120 deletions

View File

@@ -6,7 +6,7 @@ import { Dialog } from './core/Dialog';
type DialogEntry = {
id: string;
render: ({ hide }: { hide: () => void }) => React.ReactNode;
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className' | 'size'>;
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className' | 'size' | 'noPadding'>;
interface State {
dialogs: DialogEntry[];

View File

@@ -27,7 +27,10 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
const showEnvironmentDialog = useCallback(() => {
dialog.toggle({
id: 'environment-editor',
title: 'Manage Environments',
hideX: true,
noPadding: true,
size: 'lg',
className: 'h-[80vh]',
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
});
}, [dialog, activeEnvironment]);

View File

@@ -1,7 +1,6 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useWindowSize } from 'react-use';
import React, { useCallback, useMemo, useState } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useDeleteEnvironment } from '../hooks/useDeleteEnvironment';
@@ -11,17 +10,19 @@ import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import type { Environment, Workspace } from '../lib/models';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import type {
GenericCompletionConfig,
GenericCompletionOption,
} from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import type { PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
interface Props {
@@ -36,9 +37,6 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const createEnvironment = useCreateEnvironment();
const activeWorkspace = useActiveWorkspace();
const windowSize = useWindowSize();
const showSidebar = windowSize.width > 500;
const selectedEnvironment = useMemo(
() => environments.find((e) => e.id === selectedEnvironmentId) ?? null,
[environments, selectedEnvironmentId],
@@ -50,60 +48,72 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
};
return (
<div
className={classNames(
'h-full pt-1 grid gap-x-8 grid-rows-[minmax(0,1fr)]',
showSidebar ? 'grid-cols-[auto_minmax(0,1fr)]' : 'grid-cols-[minmax(0,1fr)]',
)}
>
{showSidebar && (
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-3 border-r border-gray-100 -ml-2 pb-4">
<div className="min-w-0 h-full w-full overflow-y-scroll">
<SplitLayout
name="env_editor"
defaultRatio={0.75}
layout="horizontal"
className="gap-0"
firstSlot={() => (
<aside className="w-full min-w-0 pt-2">
<div className="min-w-0 h-full overflow-y-auto pt-1">
<SidebarButton
active={selectedEnvironment?.id == null}
onClick={() => setSelectedEnvironmentId(null)}
className="group"
environment={null}
rightSlot={
<IconButton
size="sm"
title="Add sub environment"
icon="plusCircle"
iconClassName="text-gray-500 group-hover:text-gray-700"
onClick={handleCreateEnvironment}
/>
}
>
Global
Global Variables
</SidebarButton>
<div className="px-2">
<Separator className="my-3"></Separator>
</div>
{environments.map((e) => (
<SidebarButton
key={e.id}
active={selectedEnvironment?.id === e.id}
environment={e}
onClick={() => setSelectedEnvironmentId(e.id)}
>
&rarr; {e.name}
{e.name}
</SidebarButton>
))}
</div>
<Button
size="sm"
className="w-full text-center"
color="gray"
justify="center"
onClick={handleCreateEnvironment}
>
New Environment
</Button>
</aside>
)}
{activeWorkspace != null && (
<EnvironmentEditor environment={selectedEnvironment} workspace={activeWorkspace} />
)}
</div>
secondSlot={() =>
activeWorkspace != null && (
<EnvironmentEditor
className="pt-2 border-l border-highlight"
environment={selectedEnvironment}
workspace={activeWorkspace}
/>
)
}
/>
);
};
const EnvironmentEditor = function ({
environment,
workspace,
className,
}: {
environment: Environment | null;
workspace: Workspace;
className?: string;
}) {
const environments = useEnvironments();
const updateEnvironment = useUpdateEnvironment(environment?.id ?? 'n/a');
const updateEnvironment = useUpdateEnvironment(environment?.id ?? null);
const updateWorkspace = useUpdateWorkspace(workspace.id);
const deleteEnvironment = useDeleteEnvironment(environment);
const variables = environment == null ? workspace.variables : environment.variables;
const handleChange = useCallback<PairEditorProps['onChange']>(
(variables) => {
@@ -143,12 +153,96 @@ const EnvironmentEditor = function ({
return { options };
}, [environments, variables, workspace, environment]);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet, and is unusable
if (name === '') return true;
return name.match(/^[a-z_][a-z0-9_]*$/i) != null;
}, []);
return (
<VStack space={4} className={classNames(className, 'pl-4')}>
<HStack space={2} className="justify-between">
<Heading className="w-full flex items-center">
<div>{environment?.name ?? 'Global Variables'}</div>
{environment == null && (
<span className="pr-3 text-sm text-gray-600 pl-2 font-normal italic ml-auto">
Always available no matter which environment is active
</span>
)}
</Heading>
</HStack>
<PairEditor
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueAutocompleteVariables={false}
forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'}
pairs={variables}
onChange={handleChange}
/>
</VStack>
);
};
function SidebarButton({
children,
className,
active,
onClick,
rightSlot,
environment,
}: {
className?: string;
children: ReactNode;
active: boolean;
onClick: () => void;
rightSlot?: ReactNode;
environment: Environment | null;
}) {
const prompt = usePrompt();
const items = useMemo<DropdownItem[] | null>(
() =>
environment == null
? null
: [
const updateEnvironment = useUpdateEnvironment(environment?.id ?? null);
const deleteEnvironment = useDeleteEnvironment(environment);
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setShowContextMenu({ x: e.clientX, y: e.clientY });
}, []);
return (
<>
<div
className={classNames(
className,
'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center',
'px-1', // Padding to show focus border
)}
>
<Button
color="custom"
size="xs"
className={classNames(
'w-full',
active ? 'text-gray-800' : 'text-gray-600 hover:text-gray-700',
)}
justify="start"
onClick={onClick}
onContextMenu={handleContextMenu}
>
{children}
</Button>
{rightSlot}
</div>
{environment != null && (
<ContextMenu
show={showContextMenu}
onClose={() => setShowContextMenu(null)}
items={[
{
key: 'rename',
label: 'Rename',
@@ -177,68 +271,9 @@ const EnvironmentEditor = function ({
leftSlot: <Icon icon="trash" size="sm" />,
onSelect: () => deleteEnvironment.mutate(),
},
],
[deleteEnvironment, updateEnvironment, prompt, environment],
);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet, and is unusable
if (name === '') return true;
return name.match(/^[a-z_][a-z0-9_]*$/i) != null;
}, []);
return (
<VStack space={2}>
<HStack space={2} className="justify-between">
<h1 className="text-xl">{environment?.name ?? 'Global Environment'}</h1>
{items != null && (
<Dropdown items={items}>
<IconButton
icon="moreVertical"
title="Environment Actions"
size="sm"
className="!h-auto w-8"
/>
</Dropdown>
)}
</HStack>
<PairEditor
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueAutocompleteVariables={false}
forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'}
pairs={variables}
onChange={handleChange}
/>
</VStack>
);
};
function SidebarButton({
children,
className,
active,
onClick,
}: {
className?: string;
children: ReactNode;
active: boolean;
onClick: () => void;
}) {
return (
<button
tabIndex={active ? 0 : -1}
onClick={onClick}
className={classNames(
className,
'flex items-center text-sm text-left w-full mb-1 h-xs rounded px-2',
'text-gray-600 hocus:text-gray-800 focus:bg-highlightSecondary outline-none',
active && '!text-gray-900',
]}
/>
)}
>
{children}
</button>
</>
);
}

View File

@@ -38,7 +38,7 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
return (
<SplitLayout
forceVertical
layout="vertical"
style={style}
name={methodType === 'unary' ? 'grpc_messages_unary' : 'grpc_messages_streaming'}
defaultRatio={methodType === 'unary' ? 0.75 : 0.3}

View File

@@ -32,6 +32,7 @@ export function ResizeHandle({
className={classNames(
className,
'group z-10 flex',
// 'bg-blue-100/10', // For debugging
vertical ? 'w-full h-3 cursor-row-resize' : 'h-full w-3 cursor-col-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',

View File

@@ -51,6 +51,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
classNames(
className,
'max-w-full min-w-0', // Help with truncation
'hocus:opacity-100', // Force opacity for certain hover effects
'whitespace-nowrap outline-none',
'flex-shrink-0 flex items-center',
'focus-visible-or-class:ring rounded-md',

View File

@@ -14,8 +14,9 @@ export interface DialogProps {
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: 'sm' | 'md' | 'full' | 'dynamic';
size?: 'sm' | 'md' | 'lg' | 'full' | 'dynamic';
hideX?: boolean;
noPadding?: boolean;
}
export function Dialog({
@@ -27,6 +28,7 @@ export function Dialog({
title,
description,
hideX,
noPadding,
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo(
@@ -50,30 +52,36 @@ export function Dialog({
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
'gap-2 grid grid-rows-[auto_minmax(0,1fr)]',
'pt-4 relative bg-gray-50 pointer-events-auto',
'grid grid-rows-[auto_minmax(0,1fr)]',
'relative bg-gray-50 pointer-events-auto',
'rounded-lg',
'dark:border border-highlight shadow shadow-black/10',
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-6rem)]',
size === 'sm' && 'w-[25rem] max-h-[80vh]',
size === 'md' && 'w-[45rem] max-h-[80vh]',
size === 'lg' && 'w-[65rem] max-h-[80vh]',
size === 'full' && 'w-[100vw] h-[100vh]',
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
)}
>
{title ? (
<Heading className="px-6 pt-4" size={1} id={titleId}>
<Heading className="px-6 mt-4 mb-2" size={1} id={titleId}>
{title}
</Heading>
) : (
<span />
)}
{description && (
<p className="px-6" id={descriptionId}>
<p className="px-6 text-gray-700" id={descriptionId}>
{description}
</p>
)}
<div className="h-full w-full grid grid-cols-[minmax(0,1fr)] overflow-y-auto px-6 py-2">
<div
className={classNames(
'h-full w-full grid grid-cols-[minmax(0,1fr)] overflow-y-auto',
!noPadding && 'px-6 py-2',
)}
>
{children}
</div>

View File

@@ -329,7 +329,7 @@ const FormRow = memo(function FormRow({
'justify-center opacity-0 group-hover:opacity-70',
)}
>
<Icon icon="gripVertical" className="pointer-events-none" />
<Icon size="sm" icon="gripVertical" className="pointer-events-none" />
</div>
) : (
<span className="w-3" />
@@ -425,6 +425,7 @@ const FormRow = memo(function FormRow({
color="custom"
icon={!isLast ? 'trash' : 'empty'}
size="sm"
iconSize="sm"
title="Delete header"
onClick={!isLast ? handleDelete : undefined}
className="ml-0.5 opacity-0 group-hover:!opacity-100 focus-visible:!opacity-100"

View File

@@ -23,7 +23,7 @@ interface Props {
defaultRatio?: number;
minHeightPx?: number;
minWidthPx?: number;
forceVertical?: boolean;
layout?: 'responsive' | 'vertical' | 'horizontal';
}
const areaL = { gridArea: 'left' };
@@ -38,13 +38,13 @@ export function SplitLayout({
secondSlot,
className,
name,
forceVertical,
layout = 'responsive',
defaultRatio = 0.5,
minHeightPx = 10,
minWidthPx = 10,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [vertical, setVertical] = useState<boolean>(false);
const [verticalBasedOnSize, setVerticalBasedOnSize] = useState<boolean>(false);
const [widthRaw, setWidth] = useLocalStorage<number>(`${name}_width::${useActiveWorkspaceId()}`);
const [heightRaw, setHeight] = useLocalStorage<number>(
`${name}_height::${useActiveWorkspaceId()}`,
@@ -62,26 +62,27 @@ export function SplitLayout({
}
useResizeObserver(containerRef.current, ({ contentRect }) => {
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
setVerticalBasedOnSize(contentRect.width < STACK_VERTICAL_WIDTH);
});
const vertical = layout !== 'horizontal' && (layout === 'vertical' || verticalBasedOnSize);
const styles = useMemo<CSSProperties>(() => {
return {
...style,
gridTemplate:
forceVertical || vertical
? `
gridTemplate: vertical
? `
' ${areaL.gridArea}' minmax(0,${1 - height}fr)
' ${areaD.gridArea}' 0
' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr)
/ 1fr
`
: `
: `
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
/ ${1 - width}fr 0 ${width}fr
`,
};
}, [forceVertical, style, vertical, height, minHeightPx, width]);
}, [style, vertical, height, minHeightPx, width]);
const unsub = () => {
if (moveState.current !== null) {
@@ -154,7 +155,7 @@ export function SplitLayout({
<ResizeHandle
style={areaD}
isResizing={isResizing}
className={classNames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
className={classNames(vertical ? '-translate-y-1.5' : '-translate-x-1.5')}
onResizeStart={handleResizeStart}
onReset={handleReset}
side={vertical ? 'top' : 'left'}

View File

@@ -19,6 +19,7 @@ export function useCreateEnvironment() {
id: 'new-environment',
name: 'name',
title: 'New Environment',
description: 'Create multiple environments with different sets of variables',
label: 'Name',
placeholder: 'My Environment',
defaultValue: 'My Environment',