From 12b14a3159bd8e98f833a24cef339e0d72706b77 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 13 Nov 2024 09:17:56 -0700 Subject: [PATCH] feat: add headers and query params input form --- frontend/console/biome.json | 3 +- frontend/console/e2e/cron.spec.ts | 3 +- frontend/console/e2e/echo.spec.ts | 3 +- frontend/console/e2e/ingress-http.spec.ts | 4 +- frontend/console/e2e/pubsub.spec.ts | 3 +- .../src/features/modules/schema/Schema.tsx | 6 +- .../src/features/traces/TraceRequestList.tsx | 5 +- .../src/features/verbs/KeyValuePairForm.tsx | 101 +++++++++++++++ .../src/features/verbs/VerbRequestForm.tsx | 116 ++++++++++-------- .../src/features/verbs/VerbRightPanel.tsx | 7 ++ .../features/verbs/hooks/useKeyValuePairs.ts | 45 +++++++ 11 files changed, 234 insertions(+), 62 deletions(-) create mode 100644 frontend/console/src/features/verbs/KeyValuePairForm.tsx create mode 100644 frontend/console/src/features/verbs/hooks/useKeyValuePairs.ts diff --git a/frontend/console/biome.json b/frontend/console/biome.json index 5b685a7612..1809f7569d 100644 --- a/frontend/console/biome.json +++ b/frontend/console/biome.json @@ -9,6 +9,7 @@ }, "formatter": { "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js", "src/**/*.jsx", "src/**/*.cjs", "src/**/*.mjs", "tests/**/*.ts", "e2e/**/*.ts"], - "ignore": ["./node_modules", "./dist", "./src/protos", "./playwright-report"] + "ignore": ["./node_modules", "./dist", "./src/protos", "./playwright-report"], + "formatWithErrors": true } } diff --git a/frontend/console/e2e/cron.spec.ts b/frontend/console/e2e/cron.spec.ts index dd007edbe7..d408f11ed1 100644 --- a/frontend/console/e2e/cron.spec.ts +++ b/frontend/console/e2e/cron.spec.ts @@ -8,8 +8,7 @@ test('shows cron verb form', async ({ page }) => { await expect(page.locator('input#request-path')).toHaveValue('cron.thirtySeconds') await expect(page.getByText('Body', { exact: true })).toBeVisible() - await expect(page.getByText('Verb Schema', { exact: true })).toBeVisible() - await expect(page.getByText('JSONSchema', { exact: true })).toBeVisible() + await expect(page.getByText('Schema', { exact: true })).toBeVisible() }) test('send cron request', async ({ page }) => { diff --git a/frontend/console/e2e/echo.spec.ts b/frontend/console/e2e/echo.spec.ts index 207848a103..f2794c2df4 100644 --- a/frontend/console/e2e/echo.spec.ts +++ b/frontend/console/e2e/echo.spec.ts @@ -8,8 +8,7 @@ test('shows echo verb form', async ({ page }) => { await expect(page.locator('input#request-path')).toHaveValue('echo.echo') await expect(page.getByText('Body', { exact: true })).toBeVisible() - await expect(page.getByText('Verb Schema', { exact: true })).toBeVisible() - await expect(page.getByText('JSONSchema', { exact: true })).toBeVisible() + await expect(page.getByText('Schema', { exact: true })).toBeVisible() }) test('send echo request', async ({ page }) => { diff --git a/frontend/console/e2e/ingress-http.spec.ts b/frontend/console/e2e/ingress-http.spec.ts index 22e4e90d50..bd3238d79d 100644 --- a/frontend/console/e2e/ingress-http.spec.ts +++ b/frontend/console/e2e/ingress-http.spec.ts @@ -7,9 +7,7 @@ test('shows http ingress form', async ({ page }) => { await expect(page.locator('#call-type')).toHaveText('GET') await expect(page.locator('input#request-path')).toHaveValue('http://localhost:8891/get/name') - await expect(page.getByText('Body', { exact: true })).toBeVisible() - await expect(page.getByText('Verb Schema', { exact: true })).toBeVisible() - await expect(page.getByText('JSONSchema', { exact: true })).toBeVisible() + await expect(page.getByText('Schema', { exact: true })).toBeVisible() }) test('send get request with path and query params', async ({ page }) => { diff --git a/frontend/console/e2e/pubsub.spec.ts b/frontend/console/e2e/pubsub.spec.ts index cdf4d41fb6..f1c6f424d4 100644 --- a/frontend/console/e2e/pubsub.spec.ts +++ b/frontend/console/e2e/pubsub.spec.ts @@ -8,8 +8,7 @@ test('shows pubsub verb form', async ({ page }) => { await expect(page.locator('input#request-path')).toHaveValue('pubsub.cookPizza') await expect(page.getByText('Body', { exact: true })).toBeVisible() - await expect(page.getByText('Verb Schema', { exact: true })).toBeVisible() - await expect(page.getByText('JSONSchema', { exact: true })).toBeVisible() + await expect(page.getByText('Schema', { exact: true })).toBeVisible() }) test('send pubsub request', async ({ page }) => { diff --git a/frontend/console/src/features/modules/schema/Schema.tsx b/frontend/console/src/features/modules/schema/Schema.tsx index 209f20b5a5..b2772f50c1 100644 --- a/frontend/console/src/features/modules/schema/Schema.tsx +++ b/frontend/console/src/features/modules/schema/Schema.tsx @@ -124,5 +124,9 @@ export const Schema = ({ schema, moduleName, containerRect }: { schema: string; )) - return
{lines}
+ return ( +
+
{lines}
+
+ ) } diff --git a/frontend/console/src/features/traces/TraceRequestList.tsx b/frontend/console/src/features/traces/TraceRequestList.tsx index 16b7d0d957..2de351bf44 100644 --- a/frontend/console/src/features/traces/TraceRequestList.tsx +++ b/frontend/console/src/features/traces/TraceRequestList.tsx @@ -32,11 +32,12 @@ export const TraceRequestList = ({ module, verb }: { module: string; verb?: stri } } - const events = Object.entries(traceEvents).map(([_, events]) => events[0]) + // Get the root/initial event from each request trace group + const rootEvents = Object.values(traceEvents).map((events) => events[0]) return (
- +
) } diff --git a/frontend/console/src/features/verbs/KeyValuePairForm.tsx b/frontend/console/src/features/verbs/KeyValuePairForm.tsx new file mode 100644 index 0000000000..4287a1b6c3 --- /dev/null +++ b/frontend/console/src/features/verbs/KeyValuePairForm.tsx @@ -0,0 +1,101 @@ +import { CheckmarkSquare01Icon, Delete03Icon, SquareIcon } from 'hugeicons-react' +import { useEffect, useRef } from 'react' + +interface KeyValuePair { + id: string + enabled: boolean + key: string + value: string +} + +interface KeyValuePairFormProps { + keyValuePairs: KeyValuePair[] + onChange: (pairs: KeyValuePair[]) => void +} + +export const KeyValuePairForm = ({ keyValuePairs, onChange }: KeyValuePairFormProps) => { + const inputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({}) + + useEffect(() => { + if (keyValuePairs.length === 0) { + onChange([{ id: crypto.randomUUID(), enabled: false, key: '', value: '' }]) + } + }, []) + + const updatePair = (id: string, updates: Partial) => { + const updatedPairs = keyValuePairs.map((p) => { + if (p.id === id) { + const updatedPair = { ...p, ...updates } + if ('key' in updates) { + updatedPair.enabled = (updates.key?.length ?? 0) > 0 + } + return updatedPair + } + return p + }) + + const filteredPairs = updatedPairs.filter((p, index) => index === updatedPairs.length - 1 || !(p.key === '' && p.value === '')) + + const lastPair = filteredPairs[filteredPairs.length - 1] + if (lastPair?.key || lastPair?.value) { + const newId = crypto.randomUUID() + filteredPairs.push({ id: newId, enabled: false, key: '', value: '' }) + } + + onChange(filteredPairs) + + // Focus the key input of the last empty row after deletion + if ('key' in updates && updates.key === '') { + setTimeout(() => { + const lastPairId = filteredPairs[filteredPairs.length - 1].id + inputRefs.current[lastPairId]?.focus() + }, 0) + } + } + + return ( +
+
+ {keyValuePairs.map((pair, index) => ( +
+ + { + inputRefs.current[pair.id] = el + }} + type='text' + value={pair.key} + onChange={(e) => updatePair(pair.id, { key: e.target.value })} + placeholder='Key' + className='flex-1 block rounded-md border-0 py-1.5 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 dark:bg-gray-800 sm:text-sm sm:leading-6' + /> + updatePair(pair.id, { value: e.target.value })} + placeholder='Value' + className='flex-1 block rounded-md border-0 py-1.5 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 dark:bg-gray-800 sm:text-sm sm:leading-6' + /> +
+ {(pair.key || pair.value || keyValuePairs.length > 1) && index !== keyValuePairs.length - 1 && ( + + )} +
+
+ ))} +
+
+ ) +} diff --git a/frontend/console/src/features/verbs/VerbRequestForm.tsx b/frontend/console/src/features/verbs/VerbRequestForm.tsx index 90c77b3ac5..9b8db3a2fe 100644 --- a/frontend/console/src/features/verbs/VerbRequestForm.tsx +++ b/frontend/console/src/features/verbs/VerbRequestForm.tsx @@ -1,5 +1,5 @@ import { Copy01Icon } from 'hugeicons-react' -import { useCallback, useContext, useEffect, useState } from 'react' +import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { CodeEditor } from '../../components/CodeEditor' import { ResizableVerticalPanels } from '../../components/ResizableVerticalPanels' import { useClient } from '../../hooks/use-client' @@ -8,7 +8,9 @@ import type { Ref } from '../../protos/xyz/block/ftl/v1/schema/schema_pb' import { VerbService } from '../../protos/xyz/block/ftl/v1/verb_connect' import { NotificationType, NotificationsContext } from '../../providers/notifications-provider' import { classNames } from '../../utils' +import { KeyValuePairForm } from './KeyValuePairForm' import { VerbFormInput } from './VerbFormInput' +import { useKeyValuePairs } from './hooks/useKeyValuePairs' import { createVerbRequest as createCallRequest, defaultRequest, @@ -25,66 +27,80 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb const { showNotification } = useContext(NotificationsContext) const [activeTabId, setActiveTabId] = useState('body') const [bodyText, setBodyText] = useState('') - const [headersText, setHeadersText] = useState('') const [response, setResponse] = useState(null) const [error, setError] = useState(null) const [path, setPath] = useState('') const bodyTextKey = `${module?.name}-${verb?.verb?.name}-body-text` const headersTextKey = `${module?.name}-${verb?.verb?.name}-headers-text` + const queryParamsTextKey = `${module?.name}-${verb?.verb?.name}-query-params-text` + const methodsWithBody = ['POST', 'PUT', 'PATCH', 'CALL', 'CRON', 'SUB'] - useEffect(() => { - setPath(httpPopulatedRequestPath(module, verb)) + const tabs = useMemo(() => { + const method = requestType(verb) + const tabsArray = methodsWithBody.includes(method) ? [{ id: 'body', name: 'Body' }] : [] + + if (isHttpIngress(verb)) { + tabsArray.push({ id: 'headers', name: 'Headers' }) + + if (['GET', 'DELETE', 'HEAD', 'OPTIONS'].includes(method)) { + tabsArray.push({ id: 'queryParams', name: 'Query Params' }) + } + } + + return tabsArray }, [module, verb]) + const { pairs: headers, updatePairs: handleHeadersChanged, getPairsObject: getHeadersObject } = useKeyValuePairs(headersTextKey) + + const { pairs: queryParams, updatePairs: handleQueryParamsChanged, getPairsObject: getQueryParamsObject } = useKeyValuePairs(queryParamsTextKey) + useEffect(() => { if (verb) { const savedBodyValue = localStorage.getItem(bodyTextKey) const bodyValue = savedBodyValue ?? defaultRequest(verb) setBodyText(bodyValue) - const savedHeadersValue = localStorage.getItem(headersTextKey) - const headerValue = savedHeadersValue ?? '{}' - setHeadersText(headerValue) - setResponse(null) setError(null) + + // Set initial tab only when verb changes + setActiveTabId(tabs[0].id) } - }, [verb, activeTabId]) + }, [verb, bodyTextKey, tabs]) + + useEffect(() => { + setPath(httpPopulatedRequestPath(module, verb)) + }, [module, verb]) const handleBodyTextChanged = (text: string) => { setBodyText(text) localStorage.setItem(bodyTextKey, text) } - const handleHeadersTextChanged = (text: string) => { - setHeadersText(text) - localStorage.setItem(headersTextKey, text) - } - const handleTabClick = (e: React.MouseEvent, id: string) => { e.preventDefault() setActiveTabId(id) } - const tabs = [{ id: 'body', name: 'Body' }] - - if (isHttpIngress(verb)) { - tabs.push({ id: 'headers', name: 'Headers' }) - } - - tabs.push({ id: 'verbschema', name: 'Verb Schema' }, { id: 'jsonschema', name: 'JSONSchema' }) - const httpCall = (path: string) => { const method = requestType(verb) + const headerObject = getHeadersObject() + const queryParamsObject = getQueryParamsObject() + + // Construct URL with query parameters + const url = new URL(path) + for (const [key, value] of Object.entries(queryParamsObject)) { + url.searchParams.append(key, value) + } - fetch(path, { + fetch(url.toString(), { method, headers: { 'Content-Type': 'application/json', - ...JSON.parse(headersText), + ...headerObject, }, - ...(method === 'POST' || method === 'PUT' ? { body: bodyText } : {}), + ...(methodsWithBody.includes(method) ? { body: bodyText } : {}), }) .then(async (response) => { if (response.ok) { @@ -106,7 +122,7 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb module: module?.name, } as Ref - const requestBytes = createCallRequest(path, verb, bodyText, headersText) + const requestBytes = createCallRequest(path, verb, bodyText, JSON.stringify(headers)) client .call({ verb: verbRef, body: requestBytes }) .then((response) => { @@ -145,7 +161,7 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb return } - const cliCommand = generateCliCommand(verb, path, headersText, bodyText) + const cliCommand = generateCliCommand(verb, path, JSON.stringify(headers), bodyText) navigator.clipboard .writeText(cliCommand) .then(() => { @@ -211,29 +227,31 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb -
-
- {activeTabId === 'body' && ( - - - +
+ + {activeTabId === 'body' && ( +
+
+ + +
- } - bottomPanelContent={bottomText !== '' ? : null} - /> - )} - {activeTabId === 'verbschema' && } - {activeTabId === 'jsonschema' && } - {activeTabId === 'headers' && } -
+ )} + + {activeTabId === 'headers' && } + {activeTabId === 'queryParams' && } +
+ } + bottomPanelContent={bottomText !== '' ? : null} + />
) diff --git a/frontend/console/src/features/verbs/VerbRightPanel.tsx b/frontend/console/src/features/verbs/VerbRightPanel.tsx index 5b360de5ba..3d7307bfd6 100644 --- a/frontend/console/src/features/verbs/VerbRightPanel.tsx +++ b/frontend/console/src/features/verbs/VerbRightPanel.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { RightPanelAttribute } from '../../components/RightPanelAttribute' import type { Verb } from '../../protos/xyz/block/ftl/v1/console/console_pb' import type { ExpandablePanelProps } from '../console/ExpandablePanel' +import { Schema } from '../modules/schema/Schema' import { type VerbRef, httpRequestPath, ingress, isHttpIngress, verbCalls } from './verb.utils' const PanelRow = ({ verb }: { verb: VerbRef }) => { @@ -49,5 +50,11 @@ export const verbPanels = (verb?: Verb, callers?: VerbRef[]) => { }) } + panels.push({ + title: 'Schema', + expanded: true, + children: , + }) + return panels } diff --git a/frontend/console/src/features/verbs/hooks/useKeyValuePairs.ts b/frontend/console/src/features/verbs/hooks/useKeyValuePairs.ts new file mode 100644 index 0000000000..2da62f72eb --- /dev/null +++ b/frontend/console/src/features/verbs/hooks/useKeyValuePairs.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react' + +export interface KeyValuePair { + id: string + enabled: boolean + key: string + value: string +} + +export const useKeyValuePairs = (storageKey: string | null) => { + const [pairs, setPairs] = useState([]) + + useEffect(() => { + if (storageKey) { + const savedValue = localStorage.getItem(storageKey) + try { + const parsedValue = savedValue ? JSON.parse(savedValue) : [] + setPairs(parsedValue.length > 0 ? parsedValue : []) + } catch { + setPairs([]) + } + } + }, [storageKey]) + + const updatePairs = (newPairs: KeyValuePair[]) => { + setPairs(newPairs) + if (storageKey) { + localStorage.setItem(storageKey, JSON.stringify(newPairs)) + } + } + + const getPairsObject = () => + pairs + .filter((p) => p.enabled) + .reduce>((acc, p) => { + acc[p.key] = p.value + return acc + }, {}) + + return { + pairs, + updatePairs, + getPairsObject, + } +}