GraphQL autocomplete and duplicate request

This commit is contained in:
Gregory Schier
2023-03-21 23:54:45 -07:00
parent 9b8961c23d
commit 168dfb9f6b
31 changed files with 299 additions and 157 deletions

View File

@@ -58,25 +58,24 @@ async fn migrate_db(
} }
#[tauri::command] #[tauri::command]
async fn send_request( async fn send_ephemeral_request(
request: models::HttpRequest,
app_handle: AppHandle<Wry>, app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str, ) -> Result<models::HttpResponse, String> {
) -> Result<String, String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
let response = models::HttpResponse::default();
return actually_send_ephemeral_request(request, response, app_handle, pool).await;
}
let req = models::get_request(request_id, pool) async fn actually_send_ephemeral_request(
.await request: models::HttpRequest,
.expect("Failed to get request"); mut response: models::HttpResponse,
app_handle: AppHandle<Wry>,
let mut response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool) pool: &Pool<Sqlite>,
.await ) -> Result<models::HttpResponse, String> {
.expect("Failed to create response");
app_handle.emit_all("updated_response", &response).unwrap();
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let mut url_string = request.url.to_string();
let mut url_string = req.url.to_string();
let mut variables = HashMap::new(); let mut variables = HashMap::new();
variables.insert("PROJECT_ID", "project_123"); variables.insert("PROJECT_ID", "project_123");
@@ -108,7 +107,7 @@ async fn send_request(
headers.insert(USER_AGENT, HeaderValue::from_static("yaak")); headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
headers.insert(ACCEPT, HeaderValue::from_static("*/*")); headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
for h in req.headers.0 { for h in request.headers.0 {
if h.name.is_empty() && h.value.is_empty() { if h.name.is_empty() && h.value.is_empty() {
continue; continue;
} }
@@ -133,10 +132,10 @@ async fn send_request(
} }
let m = let m =
Method::from_bytes(req.method.to_uppercase().as_bytes()).expect("Failed to create method"); Method::from_bytes(request.method.to_uppercase().as_bytes()).expect("Failed to create method");
let builder = client.request(m, url_string.to_string()).headers(headers); let builder = client.request(m, url_string.to_string()).headers(headers);
let sendable_req_result = match (req.body, req.body_type) { let sendable_req_result = match (request.body, request.body_type) {
(Some(b), Some(_)) => builder.body(b).build(), (Some(b), Some(_)) => builder.body(b).build(),
_ => builder.build(), _ => builder.build(),
}; };
@@ -173,28 +172,49 @@ async fn send_request(
response.url = v.url().to_string(); response.url = v.url().to_string();
response.body = v.text().await.expect("Failed to get body"); response.body = v.text().await.expect("Failed to get body");
response.elapsed = start.elapsed().as_millis() as i64; response.elapsed = start.elapsed().as_millis() as i64;
response = models::update_response(response, pool) response = models::update_response_if_id(response, pool)
.await .await
.expect("Failed to update response"); .expect("Failed to update response");
app_handle.emit_all("updated_response", &response).unwrap(); app_handle.emit_all("updated_response", &response).unwrap();
Ok(response.id) Ok(response)
} }
Err(e) => response_err(response, e.to_string(), app_handle, pool).await, Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
} }
} }
#[tauri::command]
async fn send_request(
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
let req = models::get_request(request_id, pool)
.await
.expect("Failed to get request");
let response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
.await
.expect("Failed to create response");
app_handle.emit_all("updated_response", &response).unwrap();
actually_send_ephemeral_request(req, response, app_handle, pool).await?;
Ok(())
}
async fn response_err( async fn response_err(
mut response: models::HttpResponse, mut response: models::HttpResponse,
error: String, error: String,
app_handle: AppHandle<Wry>, app_handle: AppHandle<Wry>,
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,
) -> Result<String, String> { ) -> Result<models::HttpResponse, String> {
response.error = Some(error.clone()); response.error = Some(error.clone());
response = models::update_response(response, pool) response = models::update_response_if_id(response, pool)
.await .await
.expect("Failed to update response"); .expect("Failed to update response");
app_handle.emit_all("updated_response", &response).unwrap(); app_handle.emit_all("updated_response", &response).unwrap();
Ok(response.id) Ok(response)
} }
#[tauri::command] #[tauri::command]
@@ -268,6 +288,20 @@ async fn create_request(
Ok(created_request.id) Ok(created_request.id)
} }
#[tauri::command]
async fn duplicate_request(
id: &str,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<String, String> {
let pool = &*db_instance.lock().await;
let request = models::duplicate_request(id, pool).await.expect("Failed to duplicate request");
app_handle
.emit_all("updated_request", &request)
.unwrap();
Ok(request.id)
}
#[tauri::command] #[tauri::command]
async fn update_request( async fn update_request(
request: models::HttpRequest, request: models::HttpRequest,
@@ -458,7 +492,6 @@ fn main() {
let p = dir.join("db.sqlite"); let p = dir.join("db.sqlite");
let p_string = p.to_string_lossy().replace(' ', "%20"); let p_string = p.to_string_lossy().replace(' ', "%20");
let url = format!("sqlite://{}?mode=rwc", p_string); let url = format!("sqlite://{}?mode=rwc", p_string);
println!("DB PATH: {}", p_string);
tauri::async_runtime::block_on(async move { tauri::async_runtime::block_on(async move {
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.connect(url.as_str()) .connect(url.as_str())
@@ -501,7 +534,7 @@ fn main() {
} else { } else {
event.window().open_devtools(); event.window().open_devtools();
} }
}, }
_ => {} _ => {}
}; };
}) })
@@ -525,6 +558,8 @@ fn main() {
get_request, get_request,
requests, requests,
send_request, send_request,
send_ephemeral_request,
duplicate_request,
create_request, create_request,
create_workspace, create_workspace,
delete_workspace, delete_workspace,

View File

@@ -48,7 +48,7 @@ pub struct HttpResponseHeader {
pub value: String, pub value: String,
} }
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)] #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct HttpResponse { pub struct HttpResponse {
pub id: String, pub id: String,
@@ -180,6 +180,35 @@ pub async fn create_workspace(
get_workspace(&id, pool).await get_workspace(&id, pool).await
} }
pub async fn duplicate_request(
id: &str,
pool: &Pool<Sqlite>,
) -> Result<HttpRequest, sqlx::Error> {
let existing = get_request(id, pool).await.expect("Failed to get request to duplicate");
// TODO: Figure out how to make this better
let b2;
let body = match existing.body {
Some(b) => {
b2 = b;
Some(b2.as_str())
}
None => None,
};
upsert_request(
None,
existing.workspace_id.as_str(),
existing.name.as_str(),
existing.method.as_str(),
body,
existing.body_type,
existing.url.as_str(),
existing.headers.0,
existing.sort_priority,
pool,
).await
}
pub async fn upsert_request( pub async fn upsert_request(
id: Option<&str>, id: Option<&str>,
workspace_id: &str, workspace_id: &str,
@@ -350,6 +379,16 @@ pub async fn create_response(
get_response(&id, pool).await get_response(&id, pool).await
} }
pub async fn update_response_if_id(
response: HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
if response.id == "" {
return Ok(response);
}
return update_response(response, pool).await;
}
pub async fn update_response( pub async fn update_response(
response: HttpResponse, response: HttpResponse,
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,

View File

@@ -16,7 +16,6 @@ import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { DEFAULT_FONT_SIZE } from '../lib/constants'; import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { extractKeyValue } from '../lib/keyValueStore'; import { extractKeyValue } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, KeyValue, Workspace } from '../lib/models'; import type { HttpRequest, HttpResponse, KeyValue, Workspace } from '../lib/models';
import { convertDates } from '../lib/models';
import { AppRouter } from './AppRouter'; import { AppRouter } from './AppRouter';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -52,13 +51,13 @@ await listen('updated_request', ({ payload: request }: { payload: HttpRequest })
for (const r of requests) { for (const r of requests) {
if (r.id === request.id) { if (r.id === request.id) {
found = true; found = true;
newRequests.push(convertDates(request)); newRequests.push(request);
} else { } else {
newRequests.push(r); newRequests.push(r);
} }
} }
if (!found) { if (!found) {
newRequests.push(convertDates(request)); newRequests.push(request);
} }
return newRequests; return newRequests;
}, },
@@ -74,13 +73,13 @@ await listen('updated_response', ({ payload: response }: { payload: HttpResponse
for (const r of responses) { for (const r of responses) {
if (r.id === response.id) { if (r.id === response.id) {
found = true; found = true;
newResponses.push(convertDates(response)); newResponses.push(response);
} else { } else {
newResponses.push(r); newResponses.push(r);
} }
} }
if (!found) { if (!found) {
newResponses.push(convertDates(response)); newResponses.push(response);
} }
return newResponses; return newResponses;
}, },
@@ -94,13 +93,13 @@ await listen('updated_workspace', ({ payload: workspace }: { payload: Workspace
for (const w of workspaces) { for (const w of workspaces) {
if (w.id === workspace.id) { if (w.id === workspace.id) {
found = true; found = true;
newWorkspaces.push(convertDates(workspace)); newWorkspaces.push(workspace);
} else { } else {
newWorkspaces.push(w); newWorkspaces.push(w);
} }
} }
if (!found) { if (!found) {
newWorkspaces.push(convertDates(workspace)); newWorkspaces.push(workspace);
} }
return newWorkspaces; 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 { 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 { 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 type { EditorProps } from './core/Editor';
import { Editor } 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 { interface GraphQLBody {
query: string; query: string;
@@ -13,7 +20,7 @@ interface GraphQLBody {
operationName?: string; operationName?: string;
} }
export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: Props) { export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) {
const queryKey = useUniqueKey(); const queryKey = useUniqueKey();
const { query, variables } = useMemo<GraphQLBody>(() => { const { query, variables } = useMemo<GraphQLBody>(() => {
if (!defaultValue) { 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 ( return (
<div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]"> <div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor <Editor
key={queryKey.key} key={queryKey.key}
heightMode="auto" heightMode="auto"
defaultValue={query ?? ''} defaultValue={query ?? ''}
languageExtension={graphqlExtension}
onChange={handleChangeQuery} onChange={handleChangeQuery}
contentType="application/graphql" contentType="application/graphql"
placeholder="..." placeholder="..."
@@ -59,7 +83,6 @@ export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: P
{...extraEditorProps} {...extraEditorProps}
/> />
<Separator variant="primary" /> <Separator variant="primary" />
{/*<Separator variant="secondary" />*/}
<p className="pt-1 text-gray-500 text-sm">Variables</p> <p className="pt-1 text-gray-500 text-sm">Variables</p>
<Editor <Editor
useTemplating useTemplating

View File

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

View File

@@ -1,5 +1,5 @@
import classnames from 'classnames'; import classnames from 'classnames';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
import { useKeyValue } from '../hooks/useKeyValue'; import { useKeyValue } from '../hooks/useKeyValue';
import { useUpdateRequest } from '../hooks/useUpdateRequest'; import { useUpdateRequest } from '../hooks/useUpdateRequest';
@@ -23,6 +23,7 @@ export function RequestPane({ fullHeight, className }: Props) {
const activeRequest = useActiveRequest(); const activeRequest = useActiveRequest();
const activeRequestId = activeRequest?.id ?? null; const activeRequestId = activeRequest?.id ?? null;
const updateRequest = useUpdateRequest(activeRequestId); const updateRequest = useUpdateRequest(activeRequestId);
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const activeTab = useKeyValue<string>({ const activeTab = useKeyValue<string>({
key: ['active_request_body_tab'], key: ['active_request_body_tab'],
defaultValue: 'body', defaultValue: 'body',
@@ -34,12 +35,24 @@ export function RequestPane({ fullHeight, className }: Props) {
value: 'body', value: 'body',
label: activeRequest?.bodyType ?? 'No Body', label: activeRequest?.bodyType ?? 'No Body',
options: { options: {
onChange: (bodyType: HttpRequest['bodyType']) => { onChange: async (bodyType: HttpRequest['bodyType']) => {
const patch: Partial<HttpRequest> = { bodyType }; const patch: Partial<HttpRequest> = { bodyType };
if (bodyType == HttpRequestBodyType.GraphQL) { if (bodyType == HttpRequestBodyType.GraphQL) {
patch.method = 'POST'; 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, value: activeRequest?.bodyType ?? null,
items: [ items: [
@@ -54,7 +67,7 @@ export function RequestPane({ fullHeight, className }: Props) {
{ value: 'headers', label: 'Headers' }, { value: 'headers', label: 'Headers' },
{ value: 'auth', label: 'Auth' }, { value: 'auth', label: 'Auth' },
], ],
[activeRequest?.bodyType ?? 'n/a'], [activeRequest?.bodyType, activeRequest?.headers],
); );
const handleBodyChange = useCallback((body: string) => updateRequest.mutate({ body }), []); const handleBodyChange = useCallback((body: string) => updateRequest.mutate({ body }), []);
@@ -88,7 +101,7 @@ export function RequestPane({ fullHeight, className }: Props) {
</TabContent> </TabContent>
<TabContent value="headers"> <TabContent value="headers">
<HeaderEditor <HeaderEditor
key={activeRequestId} key={`${activeRequest.id}::${forceUpdateHeaderEditorKey}`}
headers={activeRequest.headers} headers={activeRequest.headers}
onChange={handleHeadersChange} onChange={handleHeadersChange}
/> />
@@ -123,6 +136,7 @@ export function RequestPane({ fullHeight, className }: Props) {
) : activeRequest.bodyType === HttpRequestBodyType.GraphQL ? ( ) : activeRequest.bodyType === HttpRequestBodyType.GraphQL ? (
<GraphQLEditor <GraphQLEditor
key={activeRequest.id} key={activeRequest.id}
baseRequest={activeRequest}
className="!bg-gray-50" className="!bg-gray-50"
defaultValue={activeRequest?.body ?? ''} defaultValue={activeRequest?.body ?? ''}
onChange={handleBodyChange} 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 { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
interface Props { interface Props {
className?: string; requestId: string;
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
} }
export function RequestSettingsDropdown({ className }: Props) { export function RequestSettingsDropdown({ requestId, children }: Props) {
const activeRequestId = useActiveRequestId(); const deleteRequest = useDeleteRequest(requestId ?? null);
const deleteRequest = useDeleteRequest(activeRequestId ?? null); const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
return ( return (
<Dropdown <Dropdown
items={[ items={[
{ {
label: 'Something Else', label: 'Duplicate',
onSelect: () => null, onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="camera" />, leftSlot: <Icon icon="copy" />,
}, },
'-----',
{ {
label: 'Delete Request', label: 'Delete',
onSelect: deleteRequest.mutate, onSelect: deleteRequest.mutate,
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
}, },
]} ]}
> >
<IconButton className={className} size="sm" title="Request Options" icon="gear" /> {children}
</Dropdown> </Dropdown>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -151,7 +151,15 @@
/* NOTE: Extra selector required to override default styles */ /* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip { .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; @apply transition-none;
@@ -159,7 +167,7 @@
&.cm-tooltip-autocomplete { &.cm-tooltip-autocomplete {
& > ul { & > ul {
@apply p-1 max-h-[20rem]; @apply p-1 max-h-[40vh];
} }
& > ul > li { & > ul > li {
@@ -177,5 +185,18 @@
.cm-completionIcon { .cm-completionIcon {
@apply text-sm flex items-center pb-0.5; @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 { defaultKeymap } from '@codemirror/commands';
import type { Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state'; import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view'; import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames'; import classnames from 'classnames';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useUnmount } from 'react-use';
import { IconButton } from '../IconButton'; import { IconButton } from '../IconButton';
import './Editor.css'; import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions'; import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
@@ -18,6 +18,7 @@ export interface _EditorProps {
className?: string; className?: string;
heightMode?: 'auto' | 'full'; heightMode?: 'auto' | 'full';
contentType?: string; contentType?: string;
languageExtension?: Extension;
autoFocus?: boolean; autoFocus?: boolean;
defaultValue?: string; defaultValue?: string;
placeholder?: string; placeholder?: string;
@@ -38,6 +39,7 @@ export function _Editor({
placeholder, placeholder,
useTemplating, useTemplating,
defaultValue, defaultValue,
languageExtension,
onChange, onChange,
onFocus, onFocus,
className, className,
@@ -48,12 +50,6 @@ export function _Editor({
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
const wrapperRef = useRef<HTMLDivElement | 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 // Use ref so we can update the onChange handler without re-initializing the editor
const handleChange = useRef<_EditorProps['onChange']>(onChange); const handleChange = useRef<_EditorProps['onChange']>(onChange);
useEffect(() => { useEffect(() => {
@@ -87,9 +83,11 @@ export function _Editor({
// Initialize the editor when ref mounts // Initialize the editor when ref mounts
useEffect(() => { useEffect(() => {
if (wrapperRef.current === null || cm.current !== null) return; if (wrapperRef.current === null || cm.current !== null) return;
let view: EditorView;
try { try {
const languageCompartment = new Compartment(); const languageCompartment = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete }); const langExt =
languageExtension ?? getLanguageExtension({ contentType, useTemplating, autocomplete });
const state = EditorState.create({ const state = EditorState.create({
doc: `${defaultValue ?? ''}`, doc: `${defaultValue ?? ''}`,
extensions: [ 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 }; cm.current = { view, languageCompartment };
syncGutterBg({ parent: wrapperRef.current, className });
if (autoFocus) view.focus(); if (autoFocus) view.focus();
} catch (e) { } catch (e) {
console.log('Failed to initialize Codemirror', e); console.log('Failed to initialize Codemirror', e);
} }
}, [wrapperRef.current]); return () => {
view.destroy();
useEffect(() => { cm.current = null;
if (wrapperRef.current === null) return; };
syncGutterBg({ parent: wrapperRef.current, className }); }, [wrapperRef.current, languageExtension]);
}, [className]);
const cmContainer = useMemo( const cmContainer = useMemo(
() => ( () => (

View File

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

View File

@@ -24,6 +24,6 @@ export function genericCompletion({ options, minMatch = 1 }: GenericCompletionCo
if (!matchedMinimumLength && !context.explicit) return null; if (!matchedMinimumLength && !context.explicit) return null;
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text); 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, ClockIcon,
CodeIcon, CodeIcon,
ColorWheelIcon, ColorWheelIcon,
CopyIcon,
Cross2Icon, Cross2Icon,
DividerHorizontalIcon, DividerHorizontalIcon,
DotsHorizontalIcon, DotsHorizontalIcon,
@@ -40,8 +41,11 @@ const icons = {
check: CheckIcon, check: CheckIcon,
checkbox: CheckboxIcon, checkbox: CheckboxIcon,
clock: ClockIcon, clock: ClockIcon,
chevronDown: ChevronDownIcon,
code: CodeIcon, code: CodeIcon,
colorWheel: ColorWheelIcon, colorWheel: ColorWheelIcon,
copy: CopyIcon,
dividerH: DividerHorizontalIcon,
dotsH: DotsHorizontalIcon, dotsH: DotsHorizontalIcon,
dotsV: DotsVerticalIcon, dotsV: DotsVerticalIcon,
drag: DragHandleDots2Icon, drag: DragHandleDots2Icon,
@@ -50,12 +54,10 @@ const icons = {
home: HomeIcon, home: HomeIcon,
listBullet: ListBulletIcon, listBullet: ListBulletIcon,
magicWand: MagicWandIcon, magicWand: MagicWandIcon,
chevronDown: ChevronDownIcon,
magnifyingGlass: MagnifyingGlassIcon, magnifyingGlass: MagnifyingGlassIcon,
moon: MoonIcon, moon: MoonIcon,
paperPlane: PaperPlaneIcon, paperPlane: PaperPlaneIcon,
plus: PlusIcon, plus: PlusIcon,
dividerH: DividerHorizontalIcon,
plusCircle: PlusCircledIcon, plusCircle: PlusCircledIcon,
question: QuestionMarkIcon, question: QuestionMarkIcon,
rows: RowsIcon, rows: RowsIcon,

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { useNavigate } from 'react-router-dom';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import { useActiveWorkspace } from './useActiveWorkspace'; import { useActiveWorkspace } from './useActiveWorkspace';
import { useRoutes } from './useRoutes';
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) { export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
const workspace = useActiveWorkspace(); const workspace = useActiveWorkspace();
const navigate = useNavigate(); const routes = useRoutes();
return useMutation<string, unknown, Pick<HttpRequest, 'name' | 'sortPriority'>>({ return useMutation<string, unknown, Pick<HttpRequest, 'name' | 'sortPriority'>>({
mutationFn: (patch) => { mutationFn: (patch) => {
@@ -16,8 +16,8 @@ export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean })
return invoke('create_request', { ...patch, workspaceId: workspace.id }); return invoke('create_request', { ...patch, workspaceId: workspace.id });
}, },
onSuccess: async (requestId) => { onSuccess: async (requestId) => {
if (navigateAfter) { if (navigateAfter && workspace !== null) {
navigate(`/workspaces/${workspace?.id}/requests/${requestId}`); routes.navigate('request', { workspaceId: workspace.id, requestId });
} }
}, },
}); });

View File

@@ -8,6 +8,7 @@ export function useDeleteRequest(id: string | null) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<void, string>({ return useMutation<void, string>({
mutationFn: async () => { mutationFn: async () => {
console.log('DELETE REQUEST2', id, workspaceId);
if (id === null) return; if (id === null) return;
await invoke('delete_request', { requestId: id }); await invoke('delete_request', { requestId: id });
}, },

View File

@@ -0,0 +1,26 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useRoutes } from './useRoutes';
export function useDuplicateRequest({
id,
navigateAfter,
}: {
id: string | null;
navigateAfter: boolean;
}) {
const workspaceId = useActiveWorkspaceId();
const routes = useRoutes();
return useMutation<string, string>({
mutationFn: async () => {
if (id === null) throw new Error("Can't duplicate a null request");
return invoke('duplicate_request', { id });
},
onSuccess: async (newId: string) => {
if (navigateAfter && workspaceId !== null) {
routes.navigate('request', { workspaceId, requestId: newId });
}
},
});
}

View File

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import { convertDates } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useActiveWorkspaceId } from './useActiveWorkspaceId';
export function requestsQueryKey(workspaceId: string) { export function requestsQueryKey(workspaceId: string) {
@@ -16,8 +15,7 @@ export function useRequests() {
queryKey: requestsQueryKey(workspaceId ?? 'n/a'), queryKey: requestsQueryKey(workspaceId ?? 'n/a'),
queryFn: async () => { queryFn: async () => {
if (workspaceId == null) return []; if (workspaceId == null) return [];
const requests = (await invoke('requests', { workspaceId })) as HttpRequest[]; return (await invoke('requests', { workspaceId })) as HttpRequest[];
return requests.map(convertDates);
}, },
}).data ?? [] }).data ?? []
); );

View File

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import type { HttpResponse } from '../lib/models'; import type { HttpResponse } from '../lib/models';
import { convertDates } from '../lib/models';
export function responsesQueryKey(requestId: string) { export function responsesQueryKey(requestId: string) {
return ['http_responses', { requestId }]; return ['http_responses', { requestId }];
@@ -14,10 +13,9 @@ export function useResponses(requestId: string | null) {
initialData: [], initialData: [],
queryKey: responsesQueryKey(requestId ?? 'n/a'), queryKey: responsesQueryKey(requestId ?? 'n/a'),
queryFn: async () => { queryFn: async () => {
const responses = (await invoke('responses', { return (await invoke('responses', {
requestId, requestId,
})) as HttpResponse[]; })) as HttpResponse[];
return responses.map(convertDates);
}, },
}).data ?? [] }).data ?? []
); );

View File

@@ -36,7 +36,6 @@ export function useRoutes() {
// outside caller perspective. // outside caller perspective.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedPath = routePaths[path](...(params as any)); const resolvedPath = routePaths[path](...(params as any));
console.log('NAVIGATE TO', resolvedPath, 'WITH PARAMS', params, 'AND PATH', path);
navigate(resolvedPath); navigate(resolvedPath);
}, },
paths: routePaths, paths: routePaths,

View File

@@ -1,17 +1,11 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { responsesQueryKey } from './useResponses';
export function useSendRequest(id: string | null) { export function useSendRequest(id: string | null) {
const queryClient = useQueryClient();
return useMutation<void, string>({ return useMutation<void, string>({
mutationFn: async () => { mutationFn: async () => {
if (id === null) return; if (id === null) return;
await invoke('send_request', { requestId: id }); await invoke('send_request', { requestId: id });
}, },
onSuccess: async () => {
if (id === null) return;
await queryClient.invalidateQueries(responsesQueryKey(id));
},
}).mutate; }).mutate;
} }

View File

@@ -14,11 +14,7 @@ export function useUpdateRequest(id: string | null) {
const updatedRequest = { ...request, ...patch }; const updatedRequest = { ...request, ...patch };
await invoke('update_request', { await invoke('update_request', {
request: { request: updatedRequest,
...updatedRequest,
createdAt: updatedRequest.createdAt.toISOString().replace('Z', ''),
updatedAt: updatedRequest.updatedAt.toISOString().replace('Z', ''),
},
}); });
}, },
}); });

View File

@@ -1,6 +1,5 @@
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import type { Workspace } from '../lib/models'; import type { Workspace } from '../lib/models';
import { convertDates } from '../lib/models';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
export function workspacesQueryKey() { export function workspacesQueryKey() {
@@ -10,8 +9,7 @@ export function workspacesQueryKey() {
export function useWorkspaces() { export function useWorkspaces() {
return ( return (
useQuery(workspacesQueryKey(), async () => { useQuery(workspacesQueryKey(), async () => {
const workspaces = (await invoke('workspaces')) as Workspace[]; return (await invoke('workspaces')) as Workspace[];
return workspaces.map(convertDates);
}).data ?? [] }).data ?? []
); );
} }

View File

@@ -53,20 +53,3 @@ export interface HttpResponse extends BaseModel {
readonly url: string; readonly url: string;
readonly headers: HttpHeader[]; readonly headers: HttpHeader[];
} }
export function convertDates<T extends Pick<BaseModel, 'createdAt' | 'updatedAt'>>(m: T): T {
return {
...m,
createdAt: convertDate(m.createdAt),
updatedAt: convertDate(m.updatedAt),
};
}
function convertDate(d: string | Date): Date {
if (typeof d !== 'string') {
return d;
}
const date = new Date(d);
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() - userTimezoneOffset);
}

View File

@@ -0,0 +1,6 @@
import { invoke } from '@tauri-apps/api';
import type { HttpRequest, HttpResponse } from './models';
export function sendEphemeralRequest(request: HttpRequest): Promise<HttpResponse> {
return invoke('send_ephemeral_request', { request });
}

View File

@@ -1,5 +1,4 @@
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { convertDates } from './models';
import type { HttpRequest } from './models'; import type { HttpRequest } from './models';
export async function getRequest(id: string | null): Promise<HttpRequest | null> { export async function getRequest(id: string | null): Promise<HttpRequest | null> {
@@ -8,5 +7,5 @@ export async function getRequest(id: string | null): Promise<HttpRequest | null>
if (request == null) { if (request == null) {
return null; return null;
} }
return convertDates(request); return request;
} }

View File

@@ -1,9 +1,3 @@
const height = {
"xs": "1.5rem",
"sm": "2rem",
"md": "2.5rem"
};
/** @type {import("tailwindcss").Config} */ /** @type {import("tailwindcss").Config} */
module.exports = { module.exports = {
darkMode: ["class", "[data-appearance=\"dark\"]"], darkMode: ["class", "[data-appearance=\"dark\"]"],
@@ -16,8 +10,17 @@ module.exports = {
opacity: { opacity: {
"disabled": "0.3" "disabled": "0.3"
}, },
height, height: {
lineHeight: height "xs": "1.5rem",
"sm": "2.00rem",
"md": "2.5rem"
},
lineHeight: {
// HACK: Minus 2 to account for borders inside inputs
"xs": "calc(1.5rem - 2px)",
"sm": "calc(2.0rem - 2px)",
"md": "calc(2.5rem - 2px)"
}
}, },
fontFamily: { fontFamily: {
"mono": ["JetBrains Mono", "Menlo", "monospace"], "mono": ["JetBrains Mono", "Menlo", "monospace"],