diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1d0375b6..4cdf87e0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -631,8 +631,8 @@ fn create_window(handle: &AppHandle) -> Window { .inner_size(1100.0, 600.0) .hidden_title(true) .title(match is_dev() { - true => "Yaak", - false => "Yaak Dev", + true => "Yaak Dev", + false => "Yaak", }) .title_bar_style(TitleBarStyle::Overlay) .build() diff --git a/src-web/components/App.tsx b/src-web/components/App.tsx index 4c0935b2..c6ce8d2f 100644 --- a/src-web/components/App.tsx +++ b/src-web/components/App.tsx @@ -11,8 +11,10 @@ import { DialogProvider } from './DialogContext'; import { TauriListeners } from './TauriListeners'; const queryClient = new QueryClient({ + logger: undefined, defaultOptions: { queries: { + retry: false, cacheTime: 1000 * 60 * 60 * 24, // 24 hours networkMode: 'offlineFirst', diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx index 3f97da20..07d13bf8 100644 --- a/src-web/components/GraphQLEditor.tsx +++ b/src-web/components/GraphQLEditor.tsx @@ -1,11 +1,13 @@ import { updateSchema } from 'cm6-graphql'; import type { EditorView } from 'codemirror'; import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL'; import type { HttpRequest } from '../lib/models'; -import { sendEphemeralRequest } from '../lib/sendEphemeralRequest'; +import { Button } from './core/Button'; import type { EditorProps } from './core/Editor'; -import { buildClientSchema, Editor, formatGraphQL, getIntrospectionQuery } from './core/Editor'; +import { Editor, formatGraphQL } from './core/Editor'; import { Separator } from './core/Separator'; +import { useDialog } from './DialogContext'; type Props = Pick< EditorProps, @@ -21,6 +23,9 @@ interface GraphQLBody { } export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) { + const editorViewRef = useRef(null); + const introspection = useIntrospectGraphQL(baseRequest); + const { query, variables } = useMemo(() => { if (defaultValue === undefined) { return { query: '', variables: {} }; @@ -57,39 +62,13 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi [handleChange, query], ); - const editorViewRef = useRef(null); - // Refetch the schema when the URL changes useEffect(() => { - // First, clear the schema - if (editorViewRef.current) { - updateSchema(editorViewRef.current, undefined); - } + if (editorViewRef.current === null) return; + updateSchema(editorViewRef.current, introspection.data); + }, [introspection.data]); - let unmounted = false; - const body = JSON.stringify({ - query: getIntrospectionQuery(), - operationName: 'IntrospectionQuery', - }); - sendEphemeralRequest({ ...baseRequest, body }).then((response) => { - if (unmounted) return; - if (!editorViewRef.current) return; - try { - const { data } = JSON.parse(response.body); - const schema = buildClientSchema(data); - console.log('SET SCHEMA', schema, baseRequest.url); - updateSchema(editorViewRef.current, schema); - } catch (err) { - console.log('Failed to parse introspection query', err); - } - }); - - return () => { - unmounted = true; - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [baseRequest.url]); + const dialog = useDialog(); return (
@@ -101,6 +80,23 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi onChange={handleChangeQuery} placeholder="..." ref={editorViewRef} + actions={ + introspection.error && ( + + ) + } {...extraEditorProps} /> diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 7f077a2e..9bb1be52 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -4,9 +4,10 @@ import type { ViewUpdate } from '@codemirror/view'; import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view'; import classnames from 'classnames'; import { EditorView } from 'codemirror'; -import type { MutableRefObject } from 'react'; +import type { MutableRefObject, ReactNode } from 'react'; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; import { IconButton } from '../IconButton'; +import { HStack } from '../Stacks'; import './Editor.css'; import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions'; import type { GenericCompletionConfig } from './genericCompletion'; @@ -35,6 +36,7 @@ export interface EditorProps { singleLine?: boolean; format?: (v: string) => string; autocomplete?: GenericCompletionConfig; + actions?: ReactNode; } const _Editor = forwardRef(function Editor( @@ -54,6 +56,7 @@ const _Editor = forwardRef(function Editor( singleLine, format, autocomplete, + actions, }: EditorProps, ref, ) { @@ -161,21 +164,24 @@ const _Editor = forwardRef(function Editor(
{cmContainer} {format && ( - { - if (cm.current === null) return; - const { doc } = cm.current.view.state; - const insert = format(doc.toString()); - // Update editor and blur because the cursor will reset anyway - cm.current.view.dispatch({ changes: { from: 0, to: doc.length, insert } }); - cm.current.view.contentDOM.blur(); - }} - /> + + {actions} + { + if (cm.current === null) return; + const { doc } = cm.current.view.state; + const insert = format(doc.toString()); + // Update editor and blur because the cursor will reset anyway + cm.current.view.dispatch({ changes: { from: 0, to: doc.length, insert } }); + cm.current.view.contentDOM.blur(); + }} + /> + )}
); diff --git a/src-web/hooks/useDebouncedValue.ts b/src-web/hooks/useDebouncedValue.ts new file mode 100644 index 00000000..ed26c278 --- /dev/null +++ b/src-web/hooks/useDebouncedValue.ts @@ -0,0 +1,13 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useDebouncedValue(value: T, delay = 1000) { + const [state, setState] = useState(value); + const timeout = useRef(); + + useEffect(() => { + clearTimeout(timeout.current ?? 0); + timeout.current = setTimeout(() => setState(value), delay); + }, [value, delay]); + + return state; +} diff --git a/src-web/hooks/useIntrospectGraphQL.ts b/src-web/hooks/useIntrospectGraphQL.ts new file mode 100644 index 00000000..152b909a --- /dev/null +++ b/src-web/hooks/useIntrospectGraphQL.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import type { GraphQLSchema } from 'graphql'; +import { buildClientSchema, getIntrospectionQuery } from '../components/core/Editor'; +import type { HttpRequest } from '../lib/models'; +import { sendEphemeralRequest } from '../lib/sendEphemeralRequest'; +import { useDebouncedValue } from './useDebouncedValue'; + +const introspectionRequestBody = JSON.stringify({ + query: getIntrospectionQuery(), + operationName: 'IntrospectionQuery', +}); + +export function useIntrospectGraphQL(baseRequest: HttpRequest) { + const url = useDebouncedValue(baseRequest.url); + return useQuery({ + queryKey: ['introspectGraphQL', { url }], + refetchOnWindowFocus: true, + // staleTime: 1000 * 60 * 60, // 1 hour + refetchInterval: 1000 * 60, // Refetch every minute + queryFn: async () => { + const response = await sendEphemeralRequest({ + ...baseRequest, + body: introspectionRequestBody, + }); + + if (response.error) { + return Promise.reject(new Error(response.error)); + } + + const { data } = JSON.parse(response.body); + return buildClientSchema(data); + }, + }); +}