Skip to content

Commit

Permalink
feat: add headers and query params input form
Browse files Browse the repository at this point in the history
  • Loading branch information
wesbillman committed Nov 13, 2024
1 parent 86b4cf0 commit 12b14a3
Show file tree
Hide file tree
Showing 11 changed files with 234 additions and 62 deletions.
3 changes: 2 additions & 1 deletion frontend/console/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
3 changes: 1 addition & 2 deletions frontend/console/e2e/cron.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
3 changes: 1 addition & 2 deletions frontend/console/e2e/echo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
4 changes: 1 addition & 3 deletions frontend/console/e2e/ingress-http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
3 changes: 1 addition & 2 deletions frontend/console/e2e/pubsub.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
6 changes: 5 additions & 1 deletion frontend/console/src/features/modules/schema/Schema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,9 @@ export const Schema = ({ schema, moduleName, containerRect }: { schema: string;
<SchemaLine line={l} moduleNameOverride={moduleName} containerRect={containerRect || rect} />
</div>
))
return <div className='whitespace-pre font-mono text-xs'>{lines}</div>
return (
<div className='overflow-x-auto'>
<div className='whitespace-pre font-mono text-xs'>{lines}</div>
</div>
)
}
5 changes: 3 additions & 2 deletions frontend/console/src/features/traces/TraceRequestList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className='flex flex-col h-full relative '>
<TimelineEventList events={events} selectedEventId={selectedEventId} handleEntryClicked={handleCallClicked} />
<TimelineEventList events={rootEvents} selectedEventId={selectedEventId} handleEntryClicked={handleCallClicked} />
</div>
)
}
101 changes: 101 additions & 0 deletions frontend/console/src/features/verbs/KeyValuePairForm.tsx
Original file line number Diff line number Diff line change
@@ -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<KeyValuePair>) => {
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 (
<div className='p-4'>
<div className='space-y-2'>
{keyValuePairs.map((pair, index) => (
<div key={pair.id} className='flex items-center gap-2'>
<button
type='button'
onClick={() => updatePair(pair.id, { enabled: !pair.enabled })}
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 flex-shrink-0'
>
{pair.enabled ? (
<CheckmarkSquare01Icon className='h-5 w-5 text-indigo-500 dark:text-indigo-400' />
) : (
<SquareIcon className='h-5 w-5 dark:text-gray-600' />
)}
</button>
<input
ref={(el) => {
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'
/>
<input
type='text'
value={pair.value}
onChange={(e) => 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'
/>
<div className='w-9 flex-shrink-0'>
{(pair.key || pair.value || keyValuePairs.length > 1) && index !== keyValuePairs.length - 1 && (
<button type='button' onClick={() => updatePair(pair.id, { key: '', value: '' })} className='p-2 text-gray-400 hover:text-gray-500'>
<Delete03Icon className='h-5 w-5' />
</button>
)}
</div>
</div>
))}
</div>
</div>
)
}
116 changes: 67 additions & 49 deletions frontend/console/src/features/verbs/VerbRequestForm.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand All @@ -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<string | null>(null)
const [error, setError] = useState<string | null>(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<HTMLButtonElement>, 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) {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -211,29 +227,31 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb
</div>
</div>
</div>
<div className='flex-1 overflow-hidden'>
<div className='h-full overflow-y-scroll'>
{activeTabId === 'body' && (
<ResizableVerticalPanels
topPanelContent={
<div className='relative h-full'>
<button
type='button'
onClick={handleResetBody}
className='text-sm absolute top-2 right-2 z-10 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 py-1 px-2 rounded'
>
Reset
</button>
<CodeEditor id='body-editor' value={bodyText} onTextChanged={handleBodyTextChanged} schema={schemaString} />
<div className='flex-1 overflow-hidden flex flex-col'>
<ResizableVerticalPanels
topPanelContent={
<div className='h-full overflow-auto'>
{activeTabId === 'body' && (
<div className='h-full'>
<div className='relative h-full'>
<button
type='button'
onClick={handleResetBody}
className='text-sm absolute top-2 right-2 z-10 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 py-1 px-2 rounded'
>
Reset
</button>
<CodeEditor id='body-editor' value={bodyText} onTextChanged={handleBodyTextChanged} schema={schemaString} />
</div>
</div>
}
bottomPanelContent={bottomText !== '' ? <CodeEditor id='response-editor' value={bottomText} readonly /> : null}
/>
)}
{activeTabId === 'verbschema' && <CodeEditor readonly value={verb?.schema ?? ''} />}
{activeTabId === 'jsonschema' && <CodeEditor readonly value={verb?.jsonRequestSchema ?? ''} />}
{activeTabId === 'headers' && <CodeEditor value={headersText} onTextChanged={handleHeadersTextChanged} />}
</div>
)}

{activeTabId === 'headers' && <KeyValuePairForm keyValuePairs={headers} onChange={handleHeadersChanged} />}
{activeTabId === 'queryParams' && <KeyValuePairForm keyValuePairs={queryParams} onChange={handleQueryParamsChanged} />}
</div>
}
bottomPanelContent={bottomText !== '' ? <CodeEditor id='response-editor' value={bottomText} readonly /> : null}
/>
</div>
</div>
)
Expand Down
7 changes: 7 additions & 0 deletions frontend/console/src/features/verbs/VerbRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -49,5 +50,11 @@ export const verbPanels = (verb?: Verb, callers?: VerbRef[]) => {
})
}

panels.push({
title: 'Schema',
expanded: true,
children: <Schema schema={verb?.schema || ''} />,
})

return panels
}
Loading

0 comments on commit 12b14a3

Please sign in to comment.