mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-18 15:06:58 +01:00
GraphQL autocomplete and duplicate request
This commit is contained in:
@@ -58,25 +58,24 @@ async fn migrate_db(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_request(
|
||||
async fn send_ephemeral_request(
|
||||
request: models::HttpRequest,
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
request_id: &str,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
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)
|
||||
.await
|
||||
.expect("Failed to get request");
|
||||
|
||||
let mut 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();
|
||||
|
||||
async fn actually_send_ephemeral_request(
|
||||
request: models::HttpRequest,
|
||||
mut response: models::HttpResponse,
|
||||
app_handle: AppHandle<Wry>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let mut url_string = req.url.to_string();
|
||||
let mut url_string = request.url.to_string();
|
||||
|
||||
let mut variables = HashMap::new();
|
||||
variables.insert("PROJECT_ID", "project_123");
|
||||
@@ -108,7 +107,7 @@ async fn send_request(
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
|
||||
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() {
|
||||
continue;
|
||||
}
|
||||
@@ -133,10 +132,10 @@ async fn send_request(
|
||||
}
|
||||
|
||||
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 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(),
|
||||
_ => builder.build(),
|
||||
};
|
||||
@@ -173,28 +172,49 @@ async fn send_request(
|
||||
response.url = v.url().to_string();
|
||||
response.body = v.text().await.expect("Failed to get body");
|
||||
response.elapsed = start.elapsed().as_millis() as i64;
|
||||
response = models::update_response(response, pool)
|
||||
response = models::update_response_if_id(response, pool)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[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(
|
||||
mut response: models::HttpResponse,
|
||||
error: String,
|
||||
app_handle: AppHandle<Wry>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
response.error = Some(error.clone());
|
||||
response = models::update_response(response, pool)
|
||||
response = models::update_response_if_id(response, pool)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
app_handle.emit_all("updated_response", &response).unwrap();
|
||||
Ok(response.id)
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -268,6 +288,20 @@ async fn create_request(
|
||||
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]
|
||||
async fn update_request(
|
||||
request: models::HttpRequest,
|
||||
@@ -458,7 +492,6 @@ fn main() {
|
||||
let p = dir.join("db.sqlite");
|
||||
let p_string = p.to_string_lossy().replace(' ', "%20");
|
||||
let url = format!("sqlite://{}?mode=rwc", p_string);
|
||||
println!("DB PATH: {}", p_string);
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect(url.as_str())
|
||||
@@ -501,7 +534,7 @@ fn main() {
|
||||
} else {
|
||||
event.window().open_devtools();
|
||||
}
|
||||
},
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
})
|
||||
@@ -525,6 +558,8 @@ fn main() {
|
||||
get_request,
|
||||
requests,
|
||||
send_request,
|
||||
send_ephemeral_request,
|
||||
duplicate_request,
|
||||
create_request,
|
||||
create_workspace,
|
||||
delete_workspace,
|
||||
|
||||
@@ -48,7 +48,7 @@ pub struct HttpResponseHeader {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpResponse {
|
||||
pub id: String,
|
||||
@@ -180,6 +180,35 @@ pub async fn create_workspace(
|
||||
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(
|
||||
id: Option<&str>,
|
||||
workspace_id: &str,
|
||||
@@ -350,6 +379,16 @@ pub async fn create_response(
|
||||
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(
|
||||
response: HttpResponse,
|
||||
pool: &Pool<Sqlite>,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,6 +162,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
>
|
||||
{containerStyles && (
|
||||
<VStack
|
||||
space={0.5}
|
||||
ref={initMenu}
|
||||
style={menuStyles}
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -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: '𝑥';
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
() => (
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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' };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { useActiveWorkspace } from './useActiveWorkspace';
|
||||
import { useRoutes } from './useRoutes';
|
||||
|
||||
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
|
||||
const workspace = useActiveWorkspace();
|
||||
const navigate = useNavigate();
|
||||
const routes = useRoutes();
|
||||
|
||||
return useMutation<string, unknown, Pick<HttpRequest, 'name' | 'sortPriority'>>({
|
||||
mutationFn: (patch) => {
|
||||
@@ -16,8 +16,8 @@ export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean })
|
||||
return invoke('create_request', { ...patch, workspaceId: workspace.id });
|
||||
},
|
||||
onSuccess: async (requestId) => {
|
||||
if (navigateAfter) {
|
||||
navigate(`/workspaces/${workspace?.id}/requests/${requestId}`);
|
||||
if (navigateAfter && workspace !== null) {
|
||||
routes.navigate('request', { workspaceId: workspace.id, requestId });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export function useDeleteRequest(id: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, string>({
|
||||
mutationFn: async () => {
|
||||
console.log('DELETE REQUEST2', id, workspaceId);
|
||||
if (id === null) return;
|
||||
await invoke('delete_request', { requestId: id });
|
||||
},
|
||||
|
||||
26
src-web/hooks/useDuplicateRequest.ts
Normal file
26
src-web/hooks/useDuplicateRequest.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { convertDates } from '../lib/models';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
|
||||
export function requestsQueryKey(workspaceId: string) {
|
||||
@@ -16,8 +15,7 @@ export function useRequests() {
|
||||
queryKey: requestsQueryKey(workspaceId ?? 'n/a'),
|
||||
queryFn: async () => {
|
||||
if (workspaceId == null) return [];
|
||||
const requests = (await invoke('requests', { workspaceId })) as HttpRequest[];
|
||||
return requests.map(convertDates);
|
||||
return (await invoke('requests', { workspaceId })) as HttpRequest[];
|
||||
},
|
||||
}).data ?? []
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
import { convertDates } from '../lib/models';
|
||||
|
||||
export function responsesQueryKey(requestId: string) {
|
||||
return ['http_responses', { requestId }];
|
||||
@@ -14,10 +13,9 @@ export function useResponses(requestId: string | null) {
|
||||
initialData: [],
|
||||
queryKey: responsesQueryKey(requestId ?? 'n/a'),
|
||||
queryFn: async () => {
|
||||
const responses = (await invoke('responses', {
|
||||
return (await invoke('responses', {
|
||||
requestId,
|
||||
})) as HttpResponse[];
|
||||
return responses.map(convertDates);
|
||||
},
|
||||
}).data ?? []
|
||||
);
|
||||
|
||||
@@ -36,7 +36,6 @@ export function useRoutes() {
|
||||
// outside caller perspective.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const resolvedPath = routePaths[path](...(params as any));
|
||||
console.log('NAVIGATE TO', resolvedPath, 'WITH PARAMS', params, 'AND PATH', path);
|
||||
navigate(resolvedPath);
|
||||
},
|
||||
paths: routePaths,
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { responsesQueryKey } from './useResponses';
|
||||
|
||||
export function useSendRequest(id: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, string>({
|
||||
mutationFn: async () => {
|
||||
if (id === null) return;
|
||||
await invoke('send_request', { requestId: id });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (id === null) return;
|
||||
await queryClient.invalidateQueries(responsesQueryKey(id));
|
||||
},
|
||||
}).mutate;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,7 @@ export function useUpdateRequest(id: string | null) {
|
||||
const updatedRequest = { ...request, ...patch };
|
||||
|
||||
await invoke('update_request', {
|
||||
request: {
|
||||
...updatedRequest,
|
||||
createdAt: updatedRequest.createdAt.toISOString().replace('Z', ''),
|
||||
updatedAt: updatedRequest.updatedAt.toISOString().replace('Z', ''),
|
||||
},
|
||||
request: updatedRequest,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { Workspace } from '../lib/models';
|
||||
import { convertDates } from '../lib/models';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export function workspacesQueryKey() {
|
||||
@@ -10,8 +9,7 @@ export function workspacesQueryKey() {
|
||||
export function useWorkspaces() {
|
||||
return (
|
||||
useQuery(workspacesQueryKey(), async () => {
|
||||
const workspaces = (await invoke('workspaces')) as Workspace[];
|
||||
return workspaces.map(convertDates);
|
||||
return (await invoke('workspaces')) as Workspace[];
|
||||
}).data ?? []
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,20 +53,3 @@ export interface HttpResponse extends BaseModel {
|
||||
readonly url: string;
|
||||
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);
|
||||
}
|
||||
|
||||
6
src-web/lib/sendEphemeralRequest.ts
Normal file
6
src-web/lib/sendEphemeralRequest.ts
Normal 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 });
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { convertDates } from './models';
|
||||
import type { HttpRequest } from './models';
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
return convertDates(request);
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
const height = {
|
||||
"xs": "1.5rem",
|
||||
"sm": "2rem",
|
||||
"md": "2.5rem"
|
||||
};
|
||||
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class", "[data-appearance=\"dark\"]"],
|
||||
@@ -16,8 +10,17 @@ module.exports = {
|
||||
opacity: {
|
||||
"disabled": "0.3"
|
||||
},
|
||||
height,
|
||||
lineHeight: height
|
||||
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: {
|
||||
"mono": ["JetBrains Mono", "Menlo", "monospace"],
|
||||
|
||||
Reference in New Issue
Block a user