GraphQL autocomplete and duplicate request

This commit is contained in:
Gregory Schier
2023-03-21 23:54:45 -07:00
parent 225b0956a8
commit aeda504e64
31 changed files with 299 additions and 157 deletions

View File

@@ -16,7 +16,6 @@ import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { extractKeyValue } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, KeyValue, Workspace } from '../lib/models';
import { convertDates } from '../lib/models';
import { AppRouter } from './AppRouter';
const queryClient = new QueryClient({
@@ -52,13 +51,13 @@ await listen('updated_request', ({ payload: request }: { payload: HttpRequest })
for (const r of requests) {
if (r.id === request.id) {
found = true;
newRequests.push(convertDates(request));
newRequests.push(request);
} else {
newRequests.push(r);
}
}
if (!found) {
newRequests.push(convertDates(request));
newRequests.push(request);
}
return newRequests;
},
@@ -74,13 +73,13 @@ await listen('updated_response', ({ payload: response }: { payload: HttpResponse
for (const r of responses) {
if (r.id === response.id) {
found = true;
newResponses.push(convertDates(response));
newResponses.push(response);
} else {
newResponses.push(r);
}
}
if (!found) {
newResponses.push(convertDates(response));
newResponses.push(response);
}
return newResponses;
},
@@ -94,13 +93,13 @@ await listen('updated_workspace', ({ payload: workspace }: { payload: Workspace
for (const w of workspaces) {
if (w.id === workspace.id) {
found = true;
newWorkspaces.push(convertDates(workspace));
newWorkspaces.push(workspace);
} else {
newWorkspaces.push(w);
}
}
if (!found) {
newWorkspaces.push(convertDates(workspace));
newWorkspaces.push(workspace);
}
return newWorkspaces;
});

View File

@@ -1,11 +1,18 @@
import type { Extension } from '@codemirror/state';
import { graphql } from 'cm6-graphql';
import { formatSdl } from 'format-graphql';
import { useMemo } from 'react';
import { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities';
import { useEffect, useMemo, useState } from 'react';
import { useUniqueKey } from '../hooks/useUniqueKey';
import { Separator } from './core/Separator';
import type { HttpRequest } from '../lib/models';
import { sendEphemeralRequest } from '../lib/sendEphemeralRequest';
import type { EditorProps } from './core/Editor';
import { Editor } from './core/Editor';
import { Separator } from './core/Separator';
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'defaultValue' | 'className'>;
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'defaultValue' | 'className'> & {
baseRequest: HttpRequest;
};
interface GraphQLBody {
query: string;
@@ -13,7 +20,7 @@ interface GraphQLBody {
operationName?: string;
}
export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: Props) {
export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) {
const queryKey = useUniqueKey();
const { query, variables } = useMemo<GraphQLBody>(() => {
if (!defaultValue) {
@@ -46,12 +53,29 @@ export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: P
}
};
const [graphqlExtension, setGraphqlExtension] = useState<Extension>();
useEffect(() => {
const body = JSON.stringify({
query: getIntrospectionQuery(),
operationName: 'IntrospectionQuery',
});
const req: HttpRequest = { ...baseRequest, body, id: '' };
sendEphemeralRequest(req).then((response) => {
console.log('RESPONSE', response.body);
const { data } = JSON.parse(response.body);
const schema = buildClientSchema(data);
setGraphqlExtension(graphql(schema, {}));
});
}, [baseRequest.url]);
return (
<div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
key={queryKey.key}
heightMode="auto"
defaultValue={query ?? ''}
languageExtension={graphqlExtension}
onChange={handleChangeQuery}
contentType="application/graphql"
placeholder="..."
@@ -59,7 +83,6 @@ export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: P
{...extraEditorProps}
/>
<Separator variant="primary" />
{/*<Separator variant="secondary" />*/}
<p className="pt-1 text-gray-500 text-sm">Variables</p>
<Editor
useTemplating

View File

@@ -39,17 +39,21 @@ const headerOptionsMap: Record<string, string[]> = {
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletionConfig['options'] =
headerOptionsMap[name]?.map((o, i) => ({
headerOptionsMap[name]?.map((o) => ({
label: o,
type: 'constant',
boost: 99 - i, // Max boost is 99
boost: 1, // Put above other completions
})) ?? [];
return { minMatch: MIN_MATCH, options };
};
const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
minMatch: MIN_MATCH,
options: headerNames.map((t, i) => ({ label: t, type: 'constant', boost: 99 - i })),
options: headerNames.map((t) => ({
label: t,
type: 'constant',
boost: 1, // Put above other completions
})),
};
const validateHttpHeader = (v: string) => {

View File

@@ -1,5 +1,5 @@
import classnames from 'classnames';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useKeyValue } from '../hooks/useKeyValue';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
@@ -23,6 +23,7 @@ export function RequestPane({ fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const activeRequestId = activeRequest?.id ?? null;
const updateRequest = useUpdateRequest(activeRequestId);
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const activeTab = useKeyValue<string>({
key: ['active_request_body_tab'],
defaultValue: 'body',
@@ -34,12 +35,24 @@ export function RequestPane({ fullHeight, className }: Props) {
value: 'body',
label: activeRequest?.bodyType ?? 'No Body',
options: {
onChange: (bodyType: HttpRequest['bodyType']) => {
onChange: async (bodyType: HttpRequest['bodyType']) => {
const patch: Partial<HttpRequest> = { bodyType };
if (bodyType == HttpRequestBodyType.GraphQL) {
patch.method = 'POST';
patch.headers = [
...(activeRequest?.headers.filter((h) => h.name.toLowerCase() !== 'content-type') ??
[]),
{
name: 'Content-Type',
value: 'application/json',
enabled: true,
},
];
setTimeout(() => {
setForceUpdateHeaderEditorKey((u) => u + 1);
}, 100);
}
updateRequest.mutate(patch);
await updateRequest.mutate(patch);
},
value: activeRequest?.bodyType ?? null,
items: [
@@ -54,7 +67,7 @@ export function RequestPane({ fullHeight, className }: Props) {
{ value: 'headers', label: 'Headers' },
{ value: 'auth', label: 'Auth' },
],
[activeRequest?.bodyType ?? 'n/a'],
[activeRequest?.bodyType, activeRequest?.headers],
);
const handleBodyChange = useCallback((body: string) => updateRequest.mutate({ body }), []);
@@ -88,7 +101,7 @@ export function RequestPane({ fullHeight, className }: Props) {
</TabContent>
<TabContent value="headers">
<HeaderEditor
key={activeRequestId}
key={`${activeRequest.id}::${forceUpdateHeaderEditorKey}`}
headers={activeRequest.headers}
onChange={handleHeadersChange}
/>
@@ -123,6 +136,7 @@ export function RequestPane({ fullHeight, className }: Props) {
) : activeRequest.bodyType === HttpRequestBodyType.GraphQL ? (
<GraphQLEditor
key={activeRequest.id}
baseRequest={activeRequest}
className="!bg-gray-50"
defaultValue={activeRequest?.body ?? ''}
onChange={handleBodyChange}

View File

@@ -1,33 +1,34 @@
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import type { HTMLAttributes, ReactElement } from 'react';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
interface Props {
className?: string;
requestId: string;
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
}
export function RequestSettingsDropdown({ className }: Props) {
const activeRequestId = useActiveRequestId();
const deleteRequest = useDeleteRequest(activeRequestId ?? null);
export function RequestSettingsDropdown({ requestId, children }: Props) {
const deleteRequest = useDeleteRequest(requestId ?? null);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
return (
<Dropdown
items={[
{
label: 'Something Else',
onSelect: () => null,
leftSlot: <Icon icon="camera" />,
label: 'Duplicate',
onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="copy" />,
},
'-----',
{
label: 'Delete Request',
label: 'Delete',
onSelect: deleteRequest.mutate,
leftSlot: <Icon icon="trash" />,
},
]}
>
<IconButton className={className} size="sm" title="Request Options" icon="gear" />
{children}
</Dropdown>
);
}

View File

@@ -5,19 +5,17 @@ import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useRequests } from '../hooks/useRequests';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Button } from './core/Button';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack, VStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
import { DropMarker } from './DropMarker';
import { RequestSettingsDropdown } from './RequestSettingsDropdown';
import { ToggleThemeButton } from './ToggleThemeButton';
interface Props {
@@ -204,7 +202,6 @@ const _SidebarItem = forwardRef(function SidebarItem(
{ className, requestName, requestId, workspaceId, active, sidebarWidth }: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const deleteRequest = useDeleteRequest(requestId);
const updateRequest = useUpdateRequest(requestId);
const [editing, setEditing] = useState<boolean>(false);
@@ -244,17 +241,6 @@ const _SidebarItem = forwardRef(function SidebarItem(
[active],
);
const actionItems = useMemo(
() => [
{
label: 'Delete Request',
onSelect: deleteRequest.mutate,
leftSlot: <Icon icon="trash" />,
},
],
[],
);
return (
<li
ref={ref}
@@ -295,19 +281,18 @@ const _SidebarItem = forwardRef(function SidebarItem(
</span>
)}
</Button>
<Dropdown items={actionItems}>
<RequestSettingsDropdown requestId={requestId}>
<IconButton
color="custom"
size="sm"
title="Request Options"
icon="dotsH"
className={classnames(
'absolute right-0 top-0 transition-opacity opacity-0',
'group-hover/item:opacity-100 focus-visible:opacity-100',
)}
color="custom"
size="sm"
iconSize="sm"
title="Delete request"
icon="dotsH"
/>
</Dropdown>
</RequestSettingsDropdown>
</div>
</li>
);

View File

@@ -39,7 +39,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
className="px-0"
name="url"
label="Enter URL"
containerClassName="shadow shadow-gray-100 dark:shadow-gray-0"
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
onChange={handleUrlChange}
defaultValue={url}
placeholder="https://example.com"

View File

@@ -50,7 +50,14 @@ export default function Workspace() {
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
<IconButton size="sm" title="" icon="magnifyingGlass" />
<RequestSettingsDropdown className="pointer-events-auto" />
<RequestSettingsDropdown>
<IconButton
size="sm"
title="Request Options"
icon="gear"
className="pointer-events-auto"
/>
</RequestSettingsDropdown>
</div>
</HStack>
<div

View File

@@ -162,6 +162,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
>
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
tabIndex={-1}

View File

@@ -151,7 +151,15 @@
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded overflow-hidden text-gray-900 border border-gray-200 z-50 pointer-events-auto;
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-sm;
&.cm-completionInfo-right {
@apply ml-1;
}
&.cm-completionInfo-right-narrow {
@apply ml-1;
}
* {
@apply transition-none;
@@ -159,7 +167,7 @@
&.cm-tooltip-autocomplete {
& > ul {
@apply p-1 max-h-[20rem];
@apply p-1 max-h-[40vh];
}
& > ul > li {
@@ -177,5 +185,18 @@
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5;
}
.cm-completionLabel {
}
.cm-completionDetail {
@apply ml-auto;
}
}
}
/* Add default icon. Needs low priority so it can be overwritten */
.cm-completionIcon::after {
content: '𝑥';
}

View File

@@ -1,11 +1,11 @@
import { defaultKeymap } from '@codemirror/commands';
import type { Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames';
import { EditorView } from 'codemirror';
import type { MutableRefObject } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { useUnmount } from 'react-use';
import { IconButton } from '../IconButton';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
@@ -18,6 +18,7 @@ export interface _EditorProps {
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string;
languageExtension?: Extension;
autoFocus?: boolean;
defaultValue?: string;
placeholder?: string;
@@ -38,6 +39,7 @@ export function _Editor({
placeholder,
useTemplating,
defaultValue,
languageExtension,
onChange,
onFocus,
className,
@@ -48,12 +50,6 @@ export function _Editor({
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
// Unmount the editor
useUnmount(() => {
cm.current?.view.destroy();
cm.current = null;
});
// Use ref so we can update the onChange handler without re-initializing the editor
const handleChange = useRef<_EditorProps['onChange']>(onChange);
useEffect(() => {
@@ -87,9 +83,11 @@ export function _Editor({
// Initialize the editor when ref mounts
useEffect(() => {
if (wrapperRef.current === null || cm.current !== null) return;
let view: EditorView;
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete });
const langExt =
languageExtension ?? getLanguageExtension({ contentType, useTemplating, autocomplete });
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [
@@ -106,18 +104,18 @@ export function _Editor({
}),
],
});
const view = new EditorView({ state, parent: wrapperRef.current });
view = new EditorView({ state, parent: wrapperRef.current });
cm.current = { view, languageCompartment };
syncGutterBg({ parent: wrapperRef.current, className });
if (autoFocus) view.focus();
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
}, [wrapperRef.current]);
useEffect(() => {
if (wrapperRef.current === null) return;
syncGutterBg({ parent: wrapperRef.current, className });
}, [className]);
return () => {
view.destroy();
cm.current = null;
};
}, [wrapperRef.current, languageExtension]);
const cmContainer = useMemo(
() => (

View File

@@ -32,7 +32,8 @@ import {
rectangularSelection,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { graphqlLanguageSupport } from 'cm6-graphql';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import { render } from 'react-dom';
import type { EditorProps } from './index';
import { text } from './text/extension';
import { twig } from './twig/extension';
@@ -97,6 +98,9 @@ export function getLanguageExtension({
useTemplating = false,
autocomplete,
}: Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
if (contentType === 'application/graphql') {
return graphql();
}
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? text();
if (!useTemplating) {
@@ -115,7 +119,14 @@ export const baseExtensions = [
// TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({ closeOnBlur: true, interactionDelay: 200 }),
autocompletion({
// closeOnBlur: false,
interactionDelay: 200,
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);
},
}),
syntaxHighlighting(myHighlightStyle),
EditorState.allowMultipleSelections.of(true),
];

View File

@@ -24,6 +24,6 @@ export function genericCompletion({ options, minMatch = 1 }: GenericCompletionCo
if (!matchedMinimumLength && !context.explicit) return null;
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
return { from: toMatch.from, options: optionsWithoutExactMatches };
return { from: toMatch.from, options: optionsWithoutExactMatches, info: 'hello' };
};
}

View File

@@ -7,6 +7,7 @@ import {
ClockIcon,
CodeIcon,
ColorWheelIcon,
CopyIcon,
Cross2Icon,
DividerHorizontalIcon,
DotsHorizontalIcon,
@@ -40,8 +41,11 @@ const icons = {
check: CheckIcon,
checkbox: CheckboxIcon,
clock: ClockIcon,
chevronDown: ChevronDownIcon,
code: CodeIcon,
colorWheel: ColorWheelIcon,
copy: CopyIcon,
dividerH: DividerHorizontalIcon,
dotsH: DotsHorizontalIcon,
dotsV: DotsVerticalIcon,
drag: DragHandleDots2Icon,
@@ -50,12 +54,10 @@ const icons = {
home: HomeIcon,
listBullet: ListBulletIcon,
magicWand: MagicWandIcon,
chevronDown: ChevronDownIcon,
magnifyingGlass: MagnifyingGlassIcon,
moon: MoonIcon,
paperPlane: PaperPlaneIcon,
plus: PlusIcon,
dividerH: DividerHorizontalIcon,
plusCircle: PlusCircledIcon,
question: QuestionMarkIcon,
rows: RowsIcon,

View File

@@ -127,7 +127,7 @@ export const PairEditor = memo(function PairEditor({
'@container',
'pb-2 grid',
// NOTE: Add padding to top so overflow doesn't hide drop marker
'pt-1',
'pt-1 -my-1',
)}
>
{pairs.map((p, i) => {

View File

@@ -4,6 +4,7 @@ import { forwardRef } from 'react';
const gapClasses = {
0: 'gap-0',
0.5: 'gap-0.5',
1: 'gap-1',
2: 'gap-2',
3: 'gap-3',