diff --git a/package-lock.json b/package-lock.json index 53fe27b3..9407c4be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", "react-helmet-async": "^1.3.0", + "react-hook-form": "^7.43.8", "react-router-dom": "^6.8.1", "react-use": "^17.4.0", "uuid": "^9.0.0" @@ -6334,6 +6335,21 @@ "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.43.8", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.43.8.tgz", + "integrity": "sha512-BQm+Ge5KjTk1EchDBRhdP8Pkb7MArO2jFF+UWYr3rtvh6197khi22uloLqlWeuY02ItlCzPunPsFt1/q9wQKnw==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -12107,6 +12123,12 @@ "shallowequal": "^1.1.0" } }, + "react-hook-form": { + "version": "7.43.8", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.43.8.tgz", + "integrity": "sha512-BQm+Ge5KjTk1EchDBRhdP8Pkb7MArO2jFF+UWYr3rtvh6197khi22uloLqlWeuY02ItlCzPunPsFt1/q9wQKnw==", + "requires": {} + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5af6a179..7e843767 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5169,6 +5169,7 @@ dependencies = [ name = "yaak-app" version = "0.0.0" dependencies = [ + "base64 0.21.0", "chrono", "cocoa", "deno_ast", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b5550f3f..db05810e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,23 +14,24 @@ strip = true # Automatically strip symbols from the binary. tauri-build = { version = "1.2", features = [] } [target.'cfg(target_os = "macos")'.dependencies] -objc = { version = "0.2.7" } -cocoa = { version = "0.24.1" } +objc = "0.2.7" +cocoa = "0.24.1" [dependencies] serde_json = { version = "1.0", features = ["raw_value"] } serde = { version = "1.0", features = ["derive"] } tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "updater", "window-start-dragging"] } -http = { version = "0.2.8" } +http = "0.2.8" reqwest = { version = "0.11.14", features = ["json"] } tokio = { version = "1.25.0", features = ["sync"] } -futures = { version = "0.3.26" } -deno_core = { version = "0.174.0" } +futures = "0.3.26" +deno_core = "0.174.0" deno_ast = { version = "0.24.0", features = ["transpiling"] } sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] } -uuid = { version = "1.3.0" } -rand = { version = "0.8.5" } +uuid = "1.3.0" +rand = "0.8.5" chrono = { version = "0.4.23", features = ["serde"] } +base64 = "0.21.0" [features] # by default Tauri runs in production mode diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 334aabc8..5498b510 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,27 +1,29 @@ #![cfg_attr( -all(not(debug_assertions), target_os = "windows"), -windows_subsystem = "windows" + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" )] #[cfg(target_os = "macos")] #[macro_use] extern crate objc; +use base64::Engine; use std::collections::HashMap; use std::env; use std::env::current_dir; use std::fs::create_dir_all; +use http::header::{HeaderName, ACCEPT, USER_AGENT}; use http::{HeaderMap, HeaderValue, Method}; -use http::header::{ACCEPT, HeaderName, USER_AGENT}; use reqwest::redirect::Policy; -use sqlx::{Pool, Sqlite}; +use serde::Serialize; use sqlx::migrate::Migrator; use sqlx::sqlite::SqlitePoolOptions; -use sqlx::types::{Json}; -use tauri::{AppHandle, Menu, MenuItem, State, Submenu, TitleBarStyle, Window, Wry}; -use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent}; +use sqlx::types::{Json, JsonValue}; +use sqlx::{Pool, Sqlite}; use tauri::regex::Regex; +use tauri::{AppHandle, Menu, MenuItem, Runtime, State, Submenu, TitleBarStyle, Window, Wry}; +use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent}; use tokio::sync::Mutex; use window_ext::WindowExt; @@ -133,6 +135,41 @@ async fn actually_send_ephemeral_request( headers.insert(header_name, header_value); } + if let Some(b) = &request.authentication_type { + let empty_value = &serde_json::to_value("").unwrap(); + if b == "basic" { + let a = request.authentication.0; + let auth = format!( + "{}:{}", + a.get("username") + .unwrap_or(empty_value) + .as_str() + .unwrap_or(""), + a.get("password") + .unwrap_or(empty_value) + .as_str() + .unwrap_or(""), + ); + let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth); + headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(), + ); + } else if b == "bearer" { + let token = request + .authentication + .0 + .get("token") + .unwrap_or(empty_value) + .as_str() + .unwrap_or(""); + headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + ); + } + } + let m = 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); @@ -151,7 +188,8 @@ async fn actually_send_ephemeral_request( let resp = client.execute(sendable_req).await; - let p = window.app_handle() + let p = window + .app_handle() .path_resolver() .resolve_resource("plugins/plugin.ts") .expect("failed to resolve resource"); @@ -177,7 +215,10 @@ async fn actually_send_ephemeral_request( response = models::update_response_if_id(response, window.label(), pool) .await .expect("Failed to update response"); - window.app_handle().emit_all("updated_response", &response).unwrap(); + window + .app_handle() + .emit_all("updated_response", &response) + .unwrap(); Ok(response) } Err(e) => response_err(response, e.to_string(), window, pool).await, @@ -196,10 +237,14 @@ async fn send_request( .await .expect("Failed to get request"); - let response = models::create_response(&req.id, 0, "", 0, None, "", vec![], window.label(), pool) - .await - .expect("Failed to create response"); - window.app_handle().emit_all("updated_response", &response).unwrap(); + let response = + models::create_response(&req.id, 0, "", 0, None, "", vec![], window.label(), pool) + .await + .expect("Failed to create response"); + window + .app_handle() + .emit_all("updated_response", &response) + .unwrap(); actually_send_ephemeral_request(req, response, window, pool).await?; Ok(()) @@ -215,7 +260,10 @@ async fn response_err( response = models::update_response_if_id(response, window.label(), pool) .await .expect("Failed to update response"); - window.app_handle().emit_all("updated_response", &response).unwrap(); + window + .app_handle() + .emit_all("updated_response", &response) + .unwrap(); Ok(response) } @@ -294,10 +342,11 @@ async fn create_request( window.label(), pool, ) - .await - .expect("Failed to create request"); + .await + .expect("Failed to create request"); - window.app_handle() + window + .app_handle() .emit_all("updated_request", &created_request) .unwrap(); @@ -314,7 +363,10 @@ async fn duplicate_request( let request = models::duplicate_request(id, window.label(), pool) .await .expect("Failed to duplicate request"); - window.app_handle().emit_all("updated_request", &request).unwrap(); + window + .app_handle() + .emit_all("updated_request", &request) + .unwrap(); Ok(request.id) } @@ -352,10 +404,11 @@ async fn update_request( window.label(), pool, ) - .await - .expect("Failed to update request"); + .await + .expect("Failed to update request"); - window.app_handle() + window + .app_handle() .emit_all("updated_request", updated_request) .unwrap(); @@ -444,10 +497,14 @@ async fn workspaces( .await .expect("Failed to find workspaces"); if workspaces.is_empty() { - let workspace = - models::create_workspace("My Project", "This is the default workspace", window.label(), pool) - .await - .expect("Failed to create workspace"); + let workspace = models::create_workspace( + "My Project", + "This is the default workspace", + window.label(), + pool, + ) + .await + .expect("Failed to create workspace"); Ok(vec![workspace]) } else { Ok(workspaces) @@ -597,15 +654,15 @@ fn create_window(handle: AppHandle) -> Window { window_id, tauri::WindowUrl::App("workspaces".into()), ) - .menu(menu) - .fullscreen(false) - .resizable(true) - .inner_size(1100.0, 600.0) - .hidden_title(true) - .title("Yaak") - .title_bar_style(TitleBarStyle::Overlay) - .build() - .expect("failed to build window"); + .menu(menu) + .fullscreen(false) + .resizable(true) + .inner_size(1100.0, 600.0) + .hidden_title(true) + .title("Yaak") + .title_bar_style(TitleBarStyle::Overlay) + .build() + .expect("failed to build window"); let win2 = win.clone(); win.on_menu_event(move |event| { diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 6b021009..37e71c3c 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,9 +1,9 @@ -use std::collections::HashMap; use rand::distributions::{Alphanumeric, DistString}; use serde::{Deserialize, Serialize}; use sqlx::types::chrono::NaiveDateTime; use sqlx::types::{Json, JsonValue}; use sqlx::{Pool, Sqlite}; +use std::collections::HashMap; #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -189,7 +189,11 @@ pub async fn create_workspace( get_workspace(&id, pool).await } -pub async fn duplicate_request(id: &str, updated_by: &str, pool: &Pool) -> Result { +pub async fn duplicate_request( + id: &str, + updated_by: &str, + pool: &Pool, +) -> Result { let existing = get_request(id, pool) .await .expect("Failed to get request to duplicate"); diff --git a/src-web/components/App.tsx b/src-web/components/App.tsx index 96ea0fbb..cc79d924 100644 --- a/src-web/components/App.tsx +++ b/src-web/components/App.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { persistQueryClient } from '@tanstack/react-query-persist-client'; import { invoke } from '@tauri-apps/api'; import { listen } from '@tauri-apps/api/event'; +import { appWindow } from '@tauri-apps/api/window'; import { MotionConfig } from 'framer-motion'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; @@ -16,11 +17,13 @@ import type { SidebarDisplay } from '../hooks/useSidebarDisplay'; import { sidebarDisplayDefaultValue, sidebarDisplayKey } from '../hooks/useSidebarDisplay'; import { workspacesQueryKey } from '../hooks/useWorkspaces'; import { DEFAULT_FONT_SIZE } from '../lib/constants'; +import { debounce } from '../lib/debounce'; import { extractKeyValue, getKeyValue, setKeyValue } from '../lib/keyValueStore'; import type { HttpRequest, HttpResponse, KeyValue, Workspace } from '../lib/models'; import { AppRouter } from './AppRouter'; import { DialogProvider } from './DialogContext'; -import { appWindow, WebviewWindow } from '@tauri-apps/api/window'; + +const UPDATE_DEBOUNCE_MILLIS = 500; const queryClient = new QueryClient({ defaultOptions: { @@ -33,7 +36,7 @@ const queryClient = new QueryClient({ const localStoragePersister = createSyncStoragePersister({ storage: window.localStorage, - throttleTime: 1000, + throttleTime: 1000, // 1 second }); persistQueryClient({ @@ -42,40 +45,47 @@ persistQueryClient({ maxAge: 1000 * 60 * 60 * 24, // 24 hours }); -await listen('updated_key_value', ({ payload: keyValue }: { payload: KeyValue }) => { - if (keyValue.updatedBy === appWindow.label) return; - queryClient.setQueryData(keyValueQueryKey(keyValue), extractKeyValue(keyValue)); -}); +await listen( + 'updated_key_value', + debounce(({ payload: keyValue }: { payload: KeyValue }) => { + if (keyValue.updatedBy === appWindow.label) return; + queryClient.setQueryData(keyValueQueryKey(keyValue), extractKeyValue(keyValue)); + }, UPDATE_DEBOUNCE_MILLIS), +); -await listen('updated_request', ({ payload: request }: { payload: HttpRequest }) => { - if (request.updatedBy === appWindow.label) return; +await listen( + 'updated_request', + debounce(({ payload: request }: { payload: HttpRequest }) => { + if (request.updatedBy === appWindow.label) return; - queryClient.setQueryData( - requestsQueryKey(request.workspaceId), - (requests: HttpRequest[] = []) => { - const newRequests = []; - let found = false; - for (const r of requests) { - if (r.id === request.id) { - found = true; - newRequests.push(request); - } else { - newRequests.push(r); + queryClient.setQueryData( + requestsQueryKey(request.workspaceId), + (requests: HttpRequest[] = []) => { + const newRequests = []; + let found = false; + for (const r of requests) { + if (r.id === request.id) { + found = true; + newRequests.push(request); + } else { + newRequests.push(r); + } } - } - if (!found) { - newRequests.push(request); - } - return newRequests; - }, - ); -}); + if (!found) { + newRequests.push(request); + } + return newRequests; + }, + ); + }, UPDATE_DEBOUNCE_MILLIS), +); await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => { queryClient.setQueryData( responsesQueryKey(response.requestId), (responses: HttpResponse[] = []) => { - if (response.updatedBy === appWindow.label) return; + // We want updates from every response + // if (response.updatedBy === appWindow.label) return; const newResponses = []; let found = false; @@ -95,26 +105,29 @@ await listen('updated_response', ({ payload: response }: { payload: HttpResponse ); }); -await listen('updated_workspace', ({ payload: workspace }: { payload: Workspace }) => { - queryClient.setQueryData(workspacesQueryKey(), (workspaces: Workspace[] = []) => { - if (workspace.updatedBy === appWindow.label) return; +await listen( + 'updated_workspace', + debounce(({ payload: workspace }: { payload: Workspace }) => { + queryClient.setQueryData(workspacesQueryKey(), (workspaces: Workspace[] = []) => { + if (workspace.updatedBy === appWindow.label) return; - const newWorkspaces = []; - let found = false; - for (const w of workspaces) { - if (w.id === workspace.id) { - found = true; - newWorkspaces.push(workspace); - } else { - newWorkspaces.push(w); + const newWorkspaces = []; + let found = false; + for (const w of workspaces) { + if (w.id === workspace.id) { + found = true; + newWorkspaces.push(workspace); + } else { + newWorkspaces.push(w); + } } - } - if (!found) { - newWorkspaces.push(workspace); - } - return newWorkspaces; - }); -}); + if (!found) { + newWorkspaces.push(workspace); + } + return newWorkspaces; + }); + }, UPDATE_DEBOUNCE_MILLIS), +); await listen( 'deleted_model', diff --git a/src-web/components/BasicAuth.tsx b/src-web/components/BasicAuth.tsx new file mode 100644 index 00000000..5e6204f6 --- /dev/null +++ b/src-web/components/BasicAuth.tsx @@ -0,0 +1,42 @@ +import { useUpdateRequest } from '../hooks/useUpdateRequest'; +import type { HttpRequest } from '../lib/models'; +import { Input } from './core/Input'; +import { VStack } from './core/Stacks'; + +interface Props { + requestId: string; + authentication: HttpRequest['authentication']; +} + +export function BasicAuth({ requestId, authentication }: Props) { + const updateRequest = useUpdateRequest(requestId); + + return ( + + { + updateRequest.mutate((r) => ({ + ...r, + authentication: { password: r.authentication.password, username }, + })); + }} + /> + { + updateRequest.mutate((r) => ({ + ...r, + authentication: { username: r.authentication.username, password }, + })); + }} + /> + + ); +} diff --git a/src-web/components/BearerAuth.tsx b/src-web/components/BearerAuth.tsx new file mode 100644 index 00000000..5365ce33 --- /dev/null +++ b/src-web/components/BearerAuth.tsx @@ -0,0 +1,30 @@ +import { useUpdateRequest } from '../hooks/useUpdateRequest'; +import type { HttpRequest } from '../lib/models'; +import { Input } from './core/Input'; +import { VStack } from './core/Stacks'; + +interface Props { + requestId: string; + authentication: HttpRequest['authentication']; +} + +export function BearerAuth({ requestId, authentication }: Props) { + const updateRequest = useUpdateRequest(requestId); + + return ( + + { + updateRequest.mutate((r) => ({ + ...r, + authentication: { token }, + })); + }} + /> + + ); +} diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 36ef6097..63cecab2 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -7,7 +7,17 @@ import { useKeyValue } from '../hooks/useKeyValue'; import { useUpdateRequest } from '../hooks/useUpdateRequest'; import { tryFormatJson } from '../lib/formatters'; import type { HttpHeader, HttpRequest } from '../lib/models'; -import { BODY_TYPE_GRAPHQL, BODY_TYPE_JSON, BODY_TYPE_NONE, BODY_TYPE_XML } from '../lib/models'; +import { + AUTH_TYPE_BASIC, + AUTH_TYPE_BEARER, + AUTH_TYPE_NONE, + BODY_TYPE_GRAPHQL, + BODY_TYPE_JSON, + BODY_TYPE_NONE, + BODY_TYPE_XML, +} from '../lib/models'; +import { BasicAuth } from './BasicAuth'; +import { BearerAuth } from './BearerAuth'; import { Editor } from './core/Editor'; import type { TabItem } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs'; @@ -43,10 +53,11 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN options: { value: activeRequest.bodyType, items: [ - { label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE }, { label: 'JSON', value: BODY_TYPE_JSON }, { label: 'XML', value: BODY_TYPE_XML }, { label: 'GraphQL', value: BODY_TYPE_GRAPHQL }, + { type: 'separator' }, + { label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE }, ], onChange: async (bodyType) => { const patch: Partial = { bodyType }; @@ -76,21 +87,36 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN options: { value: activeRequest.authenticationType, items: [ - { label: 'No Auth', shortLabel: 'Auth', value: null }, - { label: 'Basic', value: 'basic' }, + { label: 'Basic Auth', shortLabel: 'Basic', value: AUTH_TYPE_BASIC }, + { label: 'Bearer Token', shortLabel: 'Bearer', value: AUTH_TYPE_BEARER }, + { type: 'separator' }, + { label: 'No Authentication', shortLabel: 'Auth', value: AUTH_TYPE_NONE }, ], - onChange: async (a) => { - await updateRequest.mutate({ - authenticationType: a, - authentication: { username: '', password: '' }, - }); + onChange: async (authenticationType) => { + let authentication: HttpRequest['authentication'] = activeRequest?.authentication; + if (authenticationType === AUTH_TYPE_BASIC) { + authentication = { + username: authentication.username ?? '', + password: authentication.password ?? '', + }; + } else if (authenticationType === AUTH_TYPE_BEARER) { + authentication = { + token: authentication.token ?? '', + }; + } + await updateRequest.mutate({ authenticationType, authentication }); }, }, }, { value: 'params', label: 'URL Params' }, { value: 'headers', label: 'Headers' }, ], - [activeRequest?.bodyType, activeRequest?.headers, activeRequest?.authenticationType], + [ + activeRequest?.bodyType, + activeRequest?.headers, + activeRequest?.authenticationType, + activeRequest?.authentication, + ], ); const handleBodyChange = useCallback((body: string) => updateRequest.mutate({ body }), []); @@ -123,24 +149,38 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN label="Request body" > -
-
Hello
-
+ {activeRequest.authenticationType === AUTH_TYPE_BASIC ? ( + + ) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? ( + + ) : ( + + No Authentication {activeRequest.authenticationType} + + )}
- null} /> + null} /> {activeRequest.bodyType === BODY_TYPE_JSON ? ( ) : activeRequest.bodyType === BODY_TYPE_XML ? ( ) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? ( { - updateRequest.mutate({ id: r.id, sortPriority: i * 1000 }); + newRequests.forEach(({ id }, i) => { + const sortPriority = i * 1000; + const update = (r: HttpRequest) => ({ ...r, sortPriority }); + updateRequest.mutate({ id, update }); }); } else { - updateRequest.mutate({ - id: requestId, - sortPriority: afterPriority - (afterPriority - beforePriority) / 2, - }); + const sortPriority = afterPriority - (afterPriority - beforePriority) / 2; + const update = (r: HttpRequest) => ({ ...r, sortPriority }); + updateRequest.mutate({ id: requestId, update }); } }, [hoveredIndex, requests], @@ -149,7 +149,7 @@ const _SidebarItem = forwardRef(function SidebarItem( const [editing, setEditing] = useState(false); const handleSubmitNameEdit = useCallback(async (el: HTMLInputElement) => { - await updateRequest.mutate({ name: el.value }); + await updateRequest.mutate((r) => ({ ...r, name: el.value })); setEditing(false); }, []); diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index c81b00e9..89a89406 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -8,6 +8,11 @@ import { Portal } from '../Portal'; import { Separator } from './Separator'; import { VStack } from './Stacks'; +export type DropdownItemSeparator = { + type: 'separator'; + label?: string; +}; + export type DropdownItem = | { type?: 'default'; @@ -18,10 +23,7 @@ export type DropdownItem = rightSlot?: ReactNode; onSelect?: () => void; } - | { - type: 'separator'; - label?: string; - }; + | DropdownItemSeparator; export interface DropdownProps { children: ReactElement>; diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx index 29c99dc5..fd30f6af 100644 --- a/src-web/components/core/Input.tsx +++ b/src-web/components/core/Input.tsx @@ -69,7 +69,7 @@ export function Input({ htmlFor={id} className={classnames( labelClassName, - 'font-semibold text-sm uppercase text-gray-700', + 'font-semibold text-xs uppercase text-gray-700', hideLabel && 'sr-only', )} > diff --git a/src-web/components/core/RadioDropdown.tsx b/src-web/components/core/RadioDropdown.tsx index 186d2235..1d2bbae5 100644 --- a/src-web/components/core/RadioDropdown.tsx +++ b/src-web/components/core/RadioDropdown.tsx @@ -1,30 +1,39 @@ import { useMemo } from 'react'; -import type { DropdownProps } from './Dropdown'; +import type { DropdownItemSeparator, DropdownProps } from './Dropdown'; import { Dropdown } from './Dropdown'; import { Icon } from './Icon'; -export interface RadioDropdownItem { - label: string; - shortLabel?: string; - value: T; -} +export type RadioDropdownItem = + | { + type?: 'default'; + label: string; + shortLabel?: string; + value: string | null; + } + | DropdownItemSeparator; -export interface RadioDropdownProps { - value: T; - onChange: (value: T) => void; - items: RadioDropdownItem[]; +export interface RadioDropdownProps { + value: string | null; + onChange: (value: string | null) => void; + items: RadioDropdownItem[]; children: DropdownProps['children']; } -export function RadioDropdown({ value, items, onChange, children }: RadioDropdownProps) { +export function RadioDropdown({ value, items, onChange, children }: RadioDropdownProps) { const dropdownItems = useMemo( () => - items.map(({ label, shortLabel, value: v }) => ({ - label, - shortLabel, - onSelect: () => onChange(v), - leftSlot: , - })), + items.map((item) => { + if (item.type === 'separator') { + return item; + } else { + return { + label: item.label, + shortLabel: item.shortLabel, + onSelect: () => onChange(item.value), + leftSlot: , + }; + } + }), [value, items], ); diff --git a/src-web/components/core/Stacks.tsx b/src-web/components/core/Stacks.tsx index 76747585..bd6d72bb 100644 --- a/src-web/components/core/Stacks.tsx +++ b/src-web/components/core/Stacks.tsx @@ -54,7 +54,7 @@ export const VStack = forwardRef(function VStack( }); type BaseStackProps = HTMLAttributes & { - as?: ComponentType | 'ul'; + as?: ComponentType | 'ul' | 'form'; space?: keyof typeof gapClasses; alignItems?: 'start' | 'center'; justifyContent?: 'start' | 'center' | 'end'; diff --git a/src-web/components/core/Tabs/Tabs.tsx b/src-web/components/core/Tabs/Tabs.tsx index 2ed9c75f..7f1833bb 100644 --- a/src-web/components/core/Tabs/Tabs.tsx +++ b/src-web/components/core/Tabs/Tabs.tsx @@ -82,7 +82,9 @@ export function Tabs({ isActive ? 'bg-gray-100 text-gray-800' : 'text-gray-600 hover:text-gray-900', ); if ('options' in t) { - const option = t.options.items.find((i) => i.value === t.options?.value); + const option = t.options.items.find( + (i) => 'value' in i && i.value === t.options?.value, + ); return ( handleTabChange(t.value)} className={btnClassName} > - {option?.shortLabel ?? option?.label ?? 'Unknown'} + {option && 'shortLabel' in option + ? option.shortLabel + : option?.label ?? 'Unknown'} diff --git a/src-web/hooks/useUpdateAnyRequest.ts b/src-web/hooks/useUpdateAnyRequest.ts index 59828598..2c3ec3a4 100644 --- a/src-web/hooks/useUpdateAnyRequest.ts +++ b/src-web/hooks/useUpdateAnyRequest.ts @@ -1,18 +1,29 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; import type { HttpRequest } from '../lib/models'; import { getRequest } from '../lib/store'; +import { requestsQueryKey } from './useRequests'; export function useUpdateAnyRequest() { - return useMutation & { id: string }>({ - mutationFn: async (patch) => { - const request = await getRequest(patch.id); + const queryClient = useQueryClient(); + + return useMutation HttpRequest }>({ + mutationFn: async ({ id, update }) => { + const request = await getRequest(id); if (request === null) { throw new Error("Can't update a null request"); } - const updatedRequest = { ...request, ...patch }; - await invoke('update_request', { request: updatedRequest }); + await invoke('update_request', { request: update(request) }); + }, + onMutate: async ({ id, update }) => { + const request = await getRequest(id); + if (request === null) return; + queryClient.setQueryData( + requestsQueryKey(request?.workspaceId), + (requests: HttpRequest[] | undefined) => + requests?.map((r) => (r.id === request.id ? update(r) : r)), + ); }, }); } diff --git a/src-web/hooks/useUpdateRequest.ts b/src-web/hooks/useUpdateRequest.ts index 72fa2fa9..25331a1a 100644 --- a/src-web/hooks/useUpdateRequest.ts +++ b/src-web/hooks/useUpdateRequest.ts @@ -6,27 +6,25 @@ import { requestsQueryKey } from './useRequests'; export function useUpdateRequest(id: string | null) { const queryClient = useQueryClient(); - return useMutation>({ - mutationFn: async (patch) => { + return useMutation | ((r: HttpRequest) => HttpRequest)>({ + mutationFn: async (v) => { const request = await getRequest(id); if (request == null) { throw new Error("Can't update a null request"); } - const updatedRequest = { ...request, ...patch }; - - console.log('UPDATING REQUEST', patch); - await invoke('update_request', { - request: updatedRequest, - }); + const newRequest = typeof v === 'function' ? v(request) : { ...request, ...v }; + await invoke('update_request', { request: newRequest }); }, - onMutate: async (patch) => { + onMutate: async (v) => { const request = await getRequest(id); if (request === null) return; + + const newRequest = typeof v === 'function' ? v(request) : { ...request, ...v }; queryClient.setQueryData( requestsQueryKey(request?.workspaceId), (requests: HttpRequest[] | undefined) => - requests?.map((r) => (r.id === request.id ? { ...r, ...patch } : r)), + requests?.map((r) => (r.id === newRequest.id ? newRequest : r)), ); }, }); diff --git a/src-web/lib/models.ts b/src-web/lib/models.ts index c8099115..44f4bf84 100644 --- a/src-web/lib/models.ts +++ b/src-web/lib/models.ts @@ -24,6 +24,7 @@ export const BODY_TYPE_XML = 'text/xml'; export const AUTH_TYPE_NONE = null; export const AUTH_TYPE_BASIC = 'basic'; +export const AUTH_TYPE_BEARER = 'bearer'; export interface HttpRequest extends BaseModel { readonly workspaceId: string; @@ -33,7 +34,7 @@ export interface HttpRequest extends BaseModel { url: string; body: string | null; bodyType: string | null; - authentication: any | null; + authentication: Record; authenticationType: string | null; auth: Record; authType: string | null;