Move workspace menu, better env mgmt, QoL

This commit is contained in:
Gregory Schier
2023-10-29 09:45:16 -07:00
parent b59ea4991c
commit a87ca6af47
19 changed files with 214 additions and 104 deletions

View File

@@ -340,11 +340,11 @@ async fn create_workspace(
async fn create_environment( async fn create_environment(
workspace_id: &str, workspace_id: &str,
name: &str, name: &str,
variables: Vec<models::EnvironmentVariable>,
window: Window<Wry>, window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Environment, String> { ) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
let variables = Vec::new();
let created_environment = models::create_environment(workspace_id, name, variables, pool) let created_environment = models::create_environment(workspace_id, name, variables, pool)
.await .await
.expect("Failed to create environment"); .expect("Failed to create environment");

View File

@@ -1,8 +1,6 @@
import React, { createContext, useContext, useMemo, useState } from 'react'; import React, { createContext, useContext, useMemo, useState } from 'react';
import type { DialogProps } from './core/Dialog'; import type { DialogProps } from './core/Dialog';
import { Dialog } from './core/Dialog'; import { Dialog } from './core/Dialog';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
type DialogEntry = { type DialogEntry = {
id: string; id: string;

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { memo, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { Button } from './core/Button'; import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown'; import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
@@ -9,6 +9,8 @@ import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useDialog } from './DialogContext'; import { useDialog } from './DialogContext';
import { EnvironmentEditDialog } from './EnvironmentEditDialog'; import { EnvironmentEditDialog } from './EnvironmentEditDialog';
import { useAppRoutes } from '../hooks/useAppRoutes'; import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { usePrompt } from '../hooks/usePrompt';
type Props = { type Props = {
className?: string; className?: string;
@@ -19,36 +21,53 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
}: Props) { }: Props) {
const environments = useEnvironments(); const environments = useEnvironments();
const activeEnvironment = useActiveEnvironment(); const activeEnvironment = useActiveEnvironment();
const createEnvironment = useCreateEnvironment();
const dialog = useDialog(); const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes(); const routes = useAppRoutes();
const showEnvironmentDialog = useCallback(() => {
dialog.show({
title: "Manage Environments",
render: () => <EnvironmentEditDialog />,
});
}, [dialog]);
const items: DropdownItem[] = useMemo( const items: DropdownItem[] = useMemo(
() => [ () =>
...environments.map( environments.length === 0
(e) => ({ ? [
key: e.id, {
label: e.name, key: 'create',
rightSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : undefined, label: 'Create Environment',
onSelect: async () => { leftSlot: <Icon icon="plusCircle" />,
routes.setEnvironment(e); onSelect: async () => {
}, await createEnvironment.mutateAsync();
}), showEnvironmentDialog();
[activeEnvironment?.id], },
), },
{ type: 'separator', label: 'Environments' }, ]
{ : [
key: 'edit', ...environments.map(
label: 'Manage Environments', (e) => ({
leftSlot: <Icon icon="gear" />, key: e.id,
onSelect: async () => { label: e.name,
dialog.show({ rightSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : undefined,
title: 'Environments', onSelect: async () => {
render: () => <EnvironmentEditDialog />, routes.setEnvironment(e);
}); },
}, }),
}, [activeEnvironment?.id],
], ),
[activeEnvironment, dialog, environments, routes], { type: 'separator', label: 'Environments' },
{
key: 'edit',
label: 'Manage Environments',
leftSlot: <Icon icon="gear" />,
onSelect: showEnvironmentDialog,
},
],
[activeEnvironment, environments, routes, prompt, createEnvironment, showEnvironmentDialog],
); );
return ( return (

View File

@@ -1,6 +1,5 @@
import { useCreateEnvironment } from '../hooks/useCreateEnvironment'; import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useEnvironments } from '../hooks/useEnvironments'; import { useEnvironments } from '../hooks/useEnvironments';
import { usePrompt } from '../hooks/usePrompt';
import type { Environment } from '../lib/models'; import type { Environment } from '../lib/models';
import { Button } from './core/Button'; import { Button } from './core/Button';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -8,22 +7,25 @@ import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useAppRoutes } from '../hooks/useAppRoutes'; import { useAppRoutes } from '../hooks/useAppRoutes';
import { PairEditor } from './core/PairEditor'; import { PairEditor } from './core/PairEditor';
import type { PairEditorProps } from './core/PairEditor'; import type { PairEditorProps } from './core/PairEditor';
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment'; import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment';
import { HStack, VStack } from './core/Stacks';
import { IconButton } from './core/IconButton';
import { useDeleteEnvironment } from '../hooks/useDeleteEnvironment';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
export const EnvironmentEditDialog = function () { export const EnvironmentEditDialog = function () {
const routes = useAppRoutes(); const routes = useAppRoutes();
const prompt = usePrompt();
const environments = useEnvironments(); const environments = useEnvironments();
const createEnvironment = useCreateEnvironment(); const createEnvironment = useCreateEnvironment();
const activeEnvironment = useActiveEnvironment(); const activeEnvironment = useActiveEnvironment();
return ( return (
<div className="h-full grid gap-3 grid-cols-[auto_minmax(0,1fr)]"> <div className="h-full grid gap-3 grid-cols-[auto_minmax(0,1fr)]">
<aside className="relative h-full min-w-[200px] pr-3 border-r border-gray-200"> <VStack space={0.5} className="relative h-full min-w-[200px] pr-3 border-r border-gray-100">
{environments.map((e) => ( {environments.map((e) => (
<Button <Button
size="sm" size="xs"
className={classNames( className={classNames(
'w-full', 'w-full',
activeEnvironment?.id === e.id && 'bg-gray-100 text-gray-1000', activeEnvironment?.id === e.id && 'bg-gray-100 text-gray-1000',
@@ -41,39 +43,55 @@ export const EnvironmentEditDialog = function () {
size="sm" size="sm"
className="mr-5 absolute bottom-0 left-0 right-0" className="mr-5 absolute bottom-0 left-0 right-0"
color="gray" color="gray"
onClick={async () => { onClick={() => createEnvironment.mutate()}
const name = await prompt({
title: 'Environment Name',
defaultValue: 'My Env',
label: 'Name',
name: 'environment',
});
createEnvironment.mutate({ name });
}}
> >
New Environment New Environment
</Button> </Button>
</aside> </VStack>
{activeEnvironment != null && <EnvironmentEditor environment={activeEnvironment} />} {activeEnvironment != null && <EnvironmentEditor environment={activeEnvironment} />}
</div> </div>
); );
}; };
const EnvironmentEditor = function ({ environment }: { environment: Environment }) { const EnvironmentEditor = function ({ environment }: { environment: Environment }) {
const environments = useEnvironments();
const updateEnvironment = useUpdateEnvironment(environment.id); const updateEnvironment = useUpdateEnvironment(environment.id);
const deleteEnvironment = useDeleteEnvironment(environment);
const handleChange = useCallback<PairEditorProps['onChange']>( const handleChange = useCallback<PairEditorProps['onChange']>(
(variables) => { (variables) => {
updateEnvironment.mutate({ variables }); updateEnvironment.mutate({ variables });
}, },
[updateEnvironment], [updateEnvironment],
); );
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const otherVariableNames = environments.flatMap((e) => e.variables.map((v) => v.name));
const variableNames = otherVariableNames.filter(
(name) => !environment.variables.some((v) => v.name === name),
);
return { options: variableNames.map((name) => ({ label: name, type: 'constant' })) };
}, [environments, environment.variables]);
return ( return (
<div> <VStack space={2}>
<HStack space={2} className="justify-between">
<h1 className="text-xl">{environment.name}</h1>
<IconButton
icon="trash"
title="Delete Environment"
size="sm"
className="!h-auto w-8"
onClick={() => deleteEnvironment.mutate()}
/>
</HStack>
<PairEditor <PairEditor
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
valueAutocompleteVariables={false}
forceUpdateKey={environment.id} forceUpdateKey={environment.id}
pairs={environment.variables} pairs={environment.variables}
onChange={handleChange} onChange={handleChange}
/> />
</div> </VStack>
); );
}; };

View File

@@ -17,6 +17,8 @@ type Props = {
export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) { export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
return ( return (
<PairEditor <PairEditor
valueAutocompleteVariables
nameAutocompleteVariables
pairs={headers} pairs={headers}
onChange={onChange} onChange={onChange}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}

View File

@@ -16,13 +16,11 @@ import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import { isResponseLoading } from '../lib/models'; import { isResponseLoading } from '../lib/models';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { HStack, VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag'; import { StatusTag } from './core/StatusTag';
import { DropMarker } from './DropMarker'; import { DropMarker } from './DropMarker';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId'; import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
import { IconButton } from './core/IconButton';
import { useCreateRequest } from '../hooks/useCreateRequest'; import { useCreateRequest } from '../hooks/useCreateRequest';
import { VStack } from './core/Stacks';
interface Props { interface Props {
className?: string; className?: string;
@@ -34,7 +32,7 @@ enum ItemTypes {
export const Sidebar = memo(function Sidebar({ className }: Props) { export const Sidebar = memo(function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden(); const { hidden } = useSidebarHidden();
const createRequest = useCreateRequest({ navigateAfter: true }); const createRequest = useCreateRequest();
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
const activeRequestId = useActiveRequestId(); const activeRequestId = useActiveRequestId();
const activeEnvironmentId = useActiveEnvironmentId(); const activeEnvironmentId = useActiveEnvironmentId();
@@ -156,15 +154,6 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
return ( return (
<div aria-hidden={hidden} className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div aria-hidden={hidden} className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<HStack className="mt-1 pt-1 mx-2" justifyContent="between" alignItems="center" space={1}>
<WorkspaceActionsDropdown forDropdown={false} className="text-left mb-0" justify="start" />
<IconButton
size="sm"
icon="plusCircle"
title="Create Request"
onClick={() => createRequest.mutate({})}
/>
</HStack>
<div <div
role="menu" role="menu"
aria-orientation="vertical" aria-orientation="vertical"

View File

@@ -1,11 +1,22 @@
import { memo } from 'react'; import { memo } from 'react';
import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { useCreateRequest } from '../hooks/useCreateRequest';
export const SidebarActions = memo(function SidebarActions() { export const SidebarActions = memo(function SidebarActions() {
const createRequest = useCreateRequest();
const { hidden, toggle } = useSidebarHidden(); const { hidden, toggle } = useSidebarHidden();
if (!hidden) return null;
return ( return (
<div>
<IconButton
size="sm"
icon="plusCircle"
title="Create Request"
onClick={() => createRequest.mutate({})}
/>
<IconButton <IconButton
onClick={toggle} onClick={toggle}
className="pointer-events-auto" className="pointer-events-auto"
@@ -13,5 +24,6 @@ export const SidebarActions = memo(function SidebarActions() {
title="Show sidebar" title="Show sidebar"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'} icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/> />
</div>
); );
}); });

View File

@@ -47,6 +47,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
return ( return (
<form onSubmit={handleSubmit} className={classNames('url-bar', className)}> <form onSubmit={handleSubmit} className={classNames('url-bar', className)}>
<Input <Input
autocompleteVariables
ref={inputRef} ref={inputRef}
size={isFocused ? 'auto' : 'sm'} size={isFocused ? 'auto' : 'sm'}
hideLabel hideLabel

View File

@@ -29,7 +29,7 @@ const drag = { gridArea: 'drag' };
export default function Workspace() { export default function Workspace() {
const { setWidth, width, resetWidth } = useSidebarWidth(); const { setWidth, width, resetWidth } = useSidebarWidth();
const { hide, hidden, toggle } = useSidebarHidden(); const { hide, show, hidden, toggle } = useSidebarHidden();
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const [floating, setFloating] = useState<boolean>(false); const [floating, setFloating] = useState<boolean>(false);
@@ -64,7 +64,14 @@ export default function Workspace() {
moveState.current = { moveState.current = {
move: async (e: MouseEvent) => { move: async (e: MouseEvent) => {
e.preventDefault(); // Prevent text selection and things e.preventDefault(); // Prevent text selection and things
setWidth(startWidth + (e.clientX - mouseStartX)); const newWidth = startWidth + (e.clientX - mouseStartX);
if (newWidth < 100) {
hide();
resetWidth();
} else {
show();
setWidth(newWidth);
}
}, },
up: (e: MouseEvent) => { up: (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();

View File

@@ -153,12 +153,9 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
return ( return (
<Dropdown items={items}> <Dropdown items={items}>
<Button <Button
forDropdown
size="sm" size="sm"
className={classNames(className, 'text-gray-800 !px-2 truncate')} className={classNames(className, 'text-gray-800 !px-2 truncate')}
forDropdown
leftSlot={
<img src="https://yaak.app/logo.svg" alt="Workspace logo" className="w-4 h-4 mr-1" />
}
{...buttonProps} {...buttonProps}
> >
{activeWorkspace?.name} {activeWorkspace?.name}

View File

@@ -7,6 +7,7 @@ import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { RequestActionsDropdown } from './RequestActionsDropdown'; import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions'; import { SidebarActions } from './SidebarActions';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown'; import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
interface Props { interface Props {
className?: string; className?: string;
@@ -23,6 +24,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
> >
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center"> <HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
<SidebarActions /> <SidebarActions />
<WorkspaceActionsDropdown />
<EnvironmentActionsDropdown className="pointer-events-auto" /> <EnvironmentActionsDropdown className="pointer-events-auto" />
</HStack> </HStack>
<div className="pointer-events-none"> <div className="pointer-events-none">

View File

@@ -4,12 +4,13 @@ import { Icon } from './Icon';
interface Props { interface Props {
checked: boolean; checked: boolean;
title: string;
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
} }
export function Checkbox({ checked, onChange, className, disabled }: Props) { export function Checkbox({ checked, onChange, className, disabled, title }: Props) {
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onChange(!checked); onChange(!checked);
}, [onChange, checked]); }, [onChange, checked]);
@@ -20,6 +21,7 @@ export function Checkbox({ checked, onChange, className, disabled }: Props) {
aria-checked={checked ? 'true' : 'false'} aria-checked={checked ? 'true' : 'false'}
disabled={disabled} disabled={disabled}
onClick={handleClick} onClick={handleClick}
title={title}
className={classNames( className={classNames(
className, className,
'flex-shrink-0 w-4 h-4 border border-gray-200 rounded', 'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',

View File

@@ -11,7 +11,7 @@ export interface DialogProps {
children: ReactNode; children: ReactNode;
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
title: ReactNode; title?: ReactNode;
description?: ReactNode; description?: ReactNode;
className?: string; className?: string;
size?: 'sm' | 'md' | 'full' | 'dynamic'; size?: 'sm' | 'md' | 'full' | 'dynamic';
@@ -63,9 +63,13 @@ export function Dialog({
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]', size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
)} )}
> >
<Heading className="text-xl font-semibold w-full" id={titleId}> {title ? (
{title} <Heading className="text-xl font-semibold w-full" id={titleId}>
</Heading> {title}
</Heading>
) : (
<span />
)}
{description && <p id={descriptionId}>{description}</p>} {description && <p id={descriptionId}>{description}</p>}
<div className="h-full w-full">{children}</div> <div className="h-full w-full">{children}</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/} {/*Put close at the end so that it's the last thing to be tabbed to*/}

View File

@@ -41,6 +41,7 @@ export interface EditorProps {
wrapLines?: boolean; wrapLines?: boolean;
format?: (v: string) => string; format?: (v: string) => string;
autocomplete?: GenericCompletionConfig; autocomplete?: GenericCompletionConfig;
autocompleteVariables?: boolean;
actions?: ReactNode; actions?: ReactNode;
} }
@@ -64,12 +65,14 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
singleLine, singleLine,
format, format,
autocomplete, autocomplete,
autocompleteVariables,
actions, actions,
wrapLines, wrapLines,
}: EditorProps, }: EditorProps,
ref, ref,
) { ) {
const environment = useActiveEnvironment(); const e = useActiveEnvironment();
const environment = autocompleteVariables ? e : null;
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view); useImperativeHandle(ref, () => cm.current?.view);

View File

@@ -37,7 +37,6 @@ import { text } from './text/extension';
import { twig } from './twig/extension'; import { twig } from './twig/extension';
import { url } from './url/extension'; import { url } from './url/extension';
import type { Environment } from '../../../lib/models'; import type { Environment } from '../../../lib/models';
import { EditorView } from 'codemirror';
export const myHighlightStyle = HighlightStyle.define([ export const myHighlightStyle = HighlightStyle.define([
{ {

View File

@@ -8,7 +8,7 @@ import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks'; import { HStack, VStack } from './Stacks';
export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'onFocus'> & export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'onFocus'> &
Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete' | 'forceUpdateKey' | 'autoFocus' | 'autoSelect'> & { Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete' | 'forceUpdateKey' | 'autoFocus' | 'autoSelect' | 'autocompleteVariables'> & {
name: string; name: string;
type?: 'text' | 'password'; type?: 'text' | 'password';
label: string; label: string;

View File

@@ -10,6 +10,7 @@ import { Icon } from './Icon';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import type { InputProps } from './Input'; import type { InputProps } from './Input';
import { Input } from './Input'; import { Input } from './Input';
import type { EditorView } from 'codemirror';
export type PairEditorProps = { export type PairEditorProps = {
pairs: Pair[]; pairs: Pair[];
@@ -20,6 +21,8 @@ export type PairEditorProps = {
valuePlaceholder?: string; valuePlaceholder?: string;
nameAutocomplete?: GenericCompletionConfig; nameAutocomplete?: GenericCompletionConfig;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
nameAutocompleteVariables?: boolean;
valueAutocompleteVariables?: boolean;
nameValidate?: InputProps['validate']; nameValidate?: InputProps['validate'];
valueValidate?: InputProps['validate']; valueValidate?: InputProps['validate'];
}; };
@@ -37,17 +40,20 @@ type PairContainer = {
}; };
export const PairEditor = memo(function PairEditor({ export const PairEditor = memo(function PairEditor({
pairs: originalPairs, className,
forceUpdateKey, forceUpdateKey,
nameAutocomplete, nameAutocomplete,
valueAutocomplete, nameAutocompleteVariables,
namePlaceholder, namePlaceholder,
valuePlaceholder,
nameValidate, nameValidate,
valueValidate,
className,
onChange, onChange,
pairs: originalPairs,
valueAutocomplete,
valueAutocompleteVariables,
valuePlaceholder,
valueValidate,
}: PairEditorProps) { }: PairEditorProps) {
const [forceFocusPairId, setForceFocusPairId] = useState<string | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [pairs, setPairs] = useState<PairContainer[]>(() => { const [pairs, setPairs] = useState<PairContainer[]>(() => {
// Remove empty headers on initial render // Remove empty headers on initial render
@@ -105,6 +111,15 @@ export const PairEditor = memo(function PairEditor({
[hoveredIndex, setPairsAndSave], [hoveredIndex, setPairsAndSave],
); );
const handleSubmitRow = useCallback(
(pair: PairContainer) => {
const index = pairs.findIndex((p) => p.id === pair.id);
const id = pairs[index + 1]?.id ?? null;
setForceFocusPairId(id);
},
[pairs],
);
const handleChange = useCallback( const handleChange = useCallback(
(pair: PairContainer) => (pair: PairContainer) =>
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))), setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
@@ -152,6 +167,9 @@ export const PairEditor = memo(function PairEditor({
pairContainer={p} pairContainer={p}
className="py-1" className="py-1"
isLast={isLast} isLast={isLast}
nameAutocompleteVariables={nameAutocompleteVariables}
valueAutocompleteVariables={valueAutocompleteVariables}
forceFocusPairId={forceFocusPairId}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete} nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete} valueAutocomplete={valueAutocomplete}
@@ -159,6 +177,7 @@ export const PairEditor = memo(function PairEditor({
valuePlaceholder={valuePlaceholder} valuePlaceholder={valuePlaceholder}
nameValidate={nameValidate} nameValidate={nameValidate}
valueValidate={valueValidate} valueValidate={valueValidate}
onSubmit={handleSubmitRow}
onChange={handleChange} onChange={handleChange}
onFocus={handleFocus} onFocus={handleFocus}
onDelete={isLast ? undefined : handleDelete} onDelete={isLast ? undefined : handleDelete}
@@ -179,16 +198,20 @@ enum ItemTypes {
type FormRowProps = { type FormRowProps = {
className?: string; className?: string;
pairContainer: PairContainer; pairContainer: PairContainer;
forceFocusPairId?: string | null;
onMove: (id: string, side: 'above' | 'below') => void; onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void; onEnd: (id: string) => void;
onChange: (pair: PairContainer) => void; onChange: (pair: PairContainer) => void;
onDelete?: (pair: PairContainer) => void; onDelete?: (pair: PairContainer) => void;
onFocus?: (pair: PairContainer) => void; onFocus?: (pair: PairContainer) => void;
onSubmit?: (pair: PairContainer) => void;
isLast?: boolean; isLast?: boolean;
} & Pick< } & Pick<
PairEditorProps, PairEditorProps,
| 'nameAutocomplete' | 'nameAutocomplete'
| 'valueAutocomplete' | 'valueAutocomplete'
| 'nameAutocompleteVariables'
| 'valueAutocompleteVariables'
| 'namePlaceholder' | 'namePlaceholder'
| 'valuePlaceholder' | 'valuePlaceholder'
| 'nameValidate' | 'nameValidate'
@@ -198,23 +221,34 @@ type FormRowProps = {
const FormRow = memo(function FormRow({ const FormRow = memo(function FormRow({
className, className,
pairContainer, forceFocusPairId,
forceUpdateKey,
isLast,
nameAutocomplete,
namePlaceholder,
nameAutocompleteVariables,
valueAutocompleteVariables,
nameValidate,
onChange, onChange,
onDelete, onDelete,
onEnd,
onFocus, onFocus,
onMove, onMove,
onEnd, onSubmit,
isLast, pairContainer,
forceUpdateKey,
nameAutocomplete,
valueAutocomplete, valueAutocomplete,
namePlaceholder,
valuePlaceholder, valuePlaceholder,
nameValidate,
valueValidate, valueValidate,
}: FormRowProps) { }: FormRowProps) {
const { id } = pairContainer; const { id } = pairContainer;
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const nameInputRef = useRef<EditorView>(null);
useEffect(() => {
if (forceFocusPairId === pairContainer.id) {
nameInputRef.current?.focus();
}
}, [forceFocusPairId, pairContainer.id]);
const handleChangeEnabled = useMemo( const handleChangeEnabled = useMemo(
() => (enabled: boolean) => onChange({ id, pair: { ...pairContainer.pair, enabled } }), () => (enabled: boolean) => onChange({ id, pair: { ...pairContainer.pair, enabled } }),
@@ -237,7 +271,7 @@ const FormRow = memo(function FormRow({
const [, connectDrop] = useDrop<PairContainer>( const [, connectDrop] = useDrop<PairContainer>(
{ {
accept: ItemTypes.ROW, accept: ItemTypes.ROW,
hover: (item, monitor) => { hover: (_, monitor) => {
if (!ref.current) return; if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect(); const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
@@ -285,12 +319,18 @@ const FormRow = memo(function FormRow({
<span className="w-3" /> <span className="w-3" />
)} )}
<Checkbox <Checkbox
title={pairContainer.pair.enabled ? 'disable entry' : 'Enable item'}
disabled={isLast} disabled={isLast}
checked={isLast ? false : !!pairContainer.pair.enabled} checked={isLast ? false : !!pairContainer.pair.enabled}
className={classNames('mr-2', isLast && '!opacity-disabled')} className={classNames('mr-2', isLast && '!opacity-disabled')}
onChange={handleChangeEnabled} onChange={handleChangeEnabled}
/> />
<div <form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
onSubmit?.(pairContainer);
}}
className={classNames( className={classNames(
'grid items-center', 'grid items-center',
'@xs:gap-2 @xs:!grid-rows-1 @xs:!grid-cols-[minmax(0,1fr)_minmax(0,1fr)]', '@xs:gap-2 @xs:!grid-rows-1 @xs:!grid-cols-[minmax(0,1fr)_minmax(0,1fr)]',
@@ -298,11 +338,12 @@ const FormRow = memo(function FormRow({
)} )}
> >
<Input <Input
ref={nameInputRef}
hideLabel hideLabel
useTemplating
size="sm" size="sm"
require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value} require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value}
validate={nameValidate} validate={nameValidate}
useTemplating
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
containerClassName={classNames(isLast && 'border-dashed')} containerClassName={classNames(isLast && 'border-dashed')}
defaultValue={pairContainer.pair.name} defaultValue={pairContainer.pair.name}
@@ -312,9 +353,11 @@ const FormRow = memo(function FormRow({
onFocus={handleFocus} onFocus={handleFocus}
placeholder={namePlaceholder ?? 'name'} placeholder={namePlaceholder ?? 'name'}
autocomplete={nameAutocomplete} autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables}
/> />
<Input <Input
hideLabel hideLabel
useTemplating
size="sm" size="sm"
containerClassName={classNames(isLast && 'border-dashed')} containerClassName={classNames(isLast && 'border-dashed')}
validate={valueValidate} validate={valueValidate}
@@ -325,10 +368,10 @@ const FormRow = memo(function FormRow({
onChange={handleChangeValue} onChange={handleChangeValue}
onFocus={handleFocus} onFocus={handleFocus}
placeholder={valuePlaceholder ?? 'value'} placeholder={valuePlaceholder ?? 'value'}
useTemplating
autocomplete={valueAutocomplete?.(pairContainer.pair.name)} autocomplete={valueAutocomplete?.(pairContainer.pair.name)}
autocompleteVariables={valueAutocompleteVariables}
/> />
</div> </form>
<IconButton <IconButton
aria-hidden={!onDelete} aria-hidden={!onDelete}
disabled={!onDelete} disabled={!onDelete}

View File

@@ -1,18 +1,34 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import type { Environment } from '../lib/models'; import type { Environment } from '../lib/models';
import { environmentsQueryKey } from './useEnvironments'; import { environmentsQueryKey, useEnvironments } from './useEnvironments';
import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes'; import { useAppRoutes } from './useAppRoutes';
import { usePrompt } from './usePrompt';
import { useWorkspaces } from './useWorkspaces';
export function useCreateEnvironment() { export function useCreateEnvironment() {
const routes = useAppRoutes();
const prompt = usePrompt();
const workspaceId = useActiveWorkspaceId(); const workspaceId = useActiveWorkspaceId();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const routes = useAppRoutes(); const environments = useEnvironments();
const workspaces = useWorkspaces();
return useMutation<Environment, unknown, Pick<Environment, 'name'>>({ return useMutation<Environment, unknown, void>({
mutationFn: (patch) => { mutationFn: async () => {
return invoke('create_environment', { ...patch, workspaceId }); const name = await prompt({
name: 'name',
title: 'Create Environment',
description: 'Enter a name for the new environment',
label: 'Name',
defaultValue: 'My Environment',
});
const variables =
environments.length === 0 && workspaces.length === 1
? [{ name: 'first_variable', value: 'some reusable value' }]
: [];
return invoke('create_environment', { name, variables, workspaceId });
}, },
onSuccess: async (environment) => { onSuccess: async (environment) => {
if (workspaceId == null) return; if (workspaceId == null) return;

View File

@@ -6,7 +6,7 @@ import { useAppRoutes } from './useAppRoutes';
import { requestsQueryKey, useRequests } from './useRequests'; import { requestsQueryKey, useRequests } from './useRequests';
import { useActiveEnvironmentId } from './useActiveEnvironmentId'; import { useActiveEnvironmentId } from './useActiveEnvironmentId';
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) { export function useCreateRequest() {
const workspaceId = useActiveWorkspaceId(); const workspaceId = useActiveWorkspaceId();
const activeEnvironmentId = useActiveEnvironmentId(); const activeEnvironmentId = useActiveEnvironmentId();
const routes = useAppRoutes(); const routes = useAppRoutes();
@@ -27,13 +27,11 @@ export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean })
requestsQueryKey({ workspaceId: request.workspaceId }), requestsQueryKey({ workspaceId: request.workspaceId }),
(requests) => [...(requests ?? []), request], (requests) => [...(requests ?? []), request],
); );
if (navigateAfter) { routes.navigate('request', {
routes.navigate('request', { workspaceId: request.workspaceId,
workspaceId: request.workspaceId, requestId: request.id,
requestId: request.id, environmentId: activeEnvironmentId ?? undefined,
environmentId: activeEnvironmentId ?? undefined, });
});
}
}, },
}); });
} }