diff --git a/packages/api-explorer/src/scenes/MethodScene/utils.spec.ts b/packages/api-explorer/src/scenes/MethodScene/utils.spec.ts index 107e81288..781a57dec 100644 --- a/packages/api-explorer/src/scenes/MethodScene/utils.spec.ts +++ b/packages/api-explorer/src/scenes/MethodScene/utils.spec.ts @@ -29,67 +29,102 @@ import { createInputs } from './utils' describe('MethodScene utils', () => { describe('run-it utils', () => { - test('createInputs works with various param types', () => { - const method = api.methods.run_inline_query - const actual = createInputs(api, method) - expect(actual).toHaveLength(method.allParams.length) + describe('createInputs', () => { + test('converts delimarray to string', () => { + const method = api.methods.all_users + const actual = createInputs(api, method) + expect(actual).toHaveLength(method.allParams.length) + expect(actual[4]).toEqual({ + name: 'ids', + location: 'query', + type: 'string', + required: false, + description: 'Optional list of ids to get specific users.', + }) + }) - expect(actual).toEqual( - expect.arrayContaining([ - /** Boolean param */ - { - name: 'cache', - location: 'query', - type: 'boolean', - required: false, - description: 'Get results from cache if available.', + test('converts enums in body to string', () => { + const method = api.methods.create_query_task + const actual = createInputs(api, method) + expect(actual).toHaveLength(method.allParams.length) + expect(actual[0]).toEqual({ + name: 'body', + location: 'body', + type: { + query_id: 0, + result_format: '', + source: '', + deferred: false, + look_id: 0, + dashboard_id: '', }, - /** Number param */ - { - name: 'limit', - location: 'query', - type: 'int64', - required: false, - description: expect.any(String), - }, - /** String param */ - { - name: 'result_format', - location: 'path', - type: 'string', - required: true, - description: 'Format of result', - }, - /** Body param */ - { - name: 'body', - location: 'body', - type: expect.objectContaining({ - model: '', - view: '', - fields: [], - pivots: [], - fill_fields: [], - filters: {}, - filter_expression: '', - sorts: [], - limit: '', - column_limit: '', - total: false, - row_total: '', - subtotals: [], - vis_config: {}, - filter_config: {}, - visible_ui_sections: '', - dynamic_fields: '', - client_id: '', - query_timezone: '', - }), - required: true, - description: '', - }, - ]) - ) + required: true, + description: '', + }) + }) + + test('works with various param types', () => { + const method = api.methods.run_inline_query + const actual = createInputs(api, method) + expect(actual).toHaveLength(method.allParams.length) + + expect(actual).toEqual( + expect.arrayContaining([ + /** Boolean param */ + { + name: 'cache', + location: 'query', + type: 'boolean', + required: false, + description: 'Get results from cache if available.', + }, + /** Number param */ + { + name: 'limit', + location: 'query', + type: 'int64', + required: false, + description: expect.any(String), + }, + /** String param */ + { + name: 'result_format', + location: 'path', + type: 'string', + required: true, + description: 'Format of result', + }, + /** Body param */ + { + name: 'body', + location: 'body', + type: expect.objectContaining({ + model: '', + view: '', + fields: [], + pivots: [], + fill_fields: [], + filters: {}, + filter_expression: '', + sorts: [], + limit: '', + column_limit: '', + total: false, + row_total: '', + subtotals: [], + vis_config: {}, + filter_config: {}, + visible_ui_sections: '', + dynamic_fields: '', + client_id: '', + query_timezone: '', + }), + required: true, + description: '', + }, + ]) + ) + }) }) }) }) diff --git a/packages/api-explorer/src/scenes/MethodScene/utils.ts b/packages/api-explorer/src/scenes/MethodScene/utils.ts index ac3fe5bed..664cee49a 100644 --- a/packages/api-explorer/src/scenes/MethodScene/utils.ts +++ b/packages/api-explorer/src/scenes/MethodScene/utils.ts @@ -32,8 +32,9 @@ import { IntrinsicType, IType, IMethod, + EnumType, } from '@looker/sdk-codegen' -import { RunItInput } from '@looker/run-it' +import { RunItInput, RunItValues } from '@looker/run-it' /** * Return a default value for a given type name @@ -78,20 +79,22 @@ const createSampleBody = (spec: IApiModel, type: IType) => { /* eslint-disable @typescript-eslint/no-use-before-define */ const getSampleValue = (type: IType) => { if (type instanceof IntrinsicType) return getTypeDefault(type.name) + if (type instanceof DelimArrayType) + return getTypeDefault(type.elementType.name) + if (type instanceof EnumType) return '' if (type instanceof ArrayType) return type.customType ? [recurse(spec.types[type.customType])] : getTypeDefault(type.name) if (type instanceof HashType) return type.customType ? recurse(spec.types[type.customType]) : {} - if (type instanceof DelimArrayType) return '' return recurse(type) } /* eslint-enable @typescript-eslint/no-use-before-define */ const recurse = (type: IType) => { - const sampleBody: { [key: string]: any } = {} + const sampleBody: RunItValues = {} for (const prop of type.writeable) { const sampleValue = getSampleValue(prop.type) if (sampleValue !== undefined) { @@ -103,6 +106,18 @@ const createSampleBody = (spec: IApiModel, type: IType) => { return recurse(type) } +/** + * Convert model type to an editable type + * @param spec API model for building input editor + * @param type to convert + */ +const editType = (spec: IApiModel, type: IType) => { + if (type instanceof IntrinsicType) return type.name + // TODO create a DelimArray editing component as part of the complex type editor + if (type instanceof DelimArrayType) return 'string' + return createSampleBody(spec, type) +} + /** * Given an SDK method create and return an array of inputs for the run-it form * @param spec Api spec @@ -112,10 +127,7 @@ export const createInputs = (spec: IApiModel, method: IMethod): RunItInput[] => method.allParams.map((param) => ({ name: param.name, location: param.location, - type: - param.type instanceof IntrinsicType - ? param.type.name - : createSampleBody(spec, param.type), + type: editType(spec, param.type), required: param.required, description: param.description, })) diff --git a/packages/code-editor/src/CodeDisplay/CodeDisplay.tsx b/packages/code-editor/src/CodeDisplay/CodeDisplay.tsx index 67e22fa59..9f260cd3c 100644 --- a/packages/code-editor/src/CodeDisplay/CodeDisplay.tsx +++ b/packages/code-editor/src/CodeDisplay/CodeDisplay.tsx @@ -39,6 +39,7 @@ require('prismjs/components/prism-kotlin') require('prismjs/components/prism-csharp') require('prismjs/components/prism-swift') require('prismjs/components/prism-ruby') +require('prismjs/components/prism-markdown') const Line = styled(Span)` display: table-row; diff --git a/packages/run-it/src/components/RequestForm/FormItem.tsx b/packages/run-it/src/components/RequestForm/FormItem.tsx new file mode 100644 index 000000000..9c76dc376 --- /dev/null +++ b/packages/run-it/src/components/RequestForm/FormItem.tsx @@ -0,0 +1,56 @@ +/* + + MIT License + + Copyright (c) 2021 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ +import React, { FC, ReactElement } from 'react' +import { Space, Box, Label } from '@looker/components' + +interface FormItemProps { + /** ID of input item for label */ + id: string + /** Optional label. Defaults to an empty string so spacing is preserved */ + label?: string + /** Nested react elements */ + children: ReactElement +} + +/** + * basic input form layout component + * @param id of input item + * @param children embedded react elements + * @param label optional label + */ +export const FormItem: FC = ({ id, children, label = ' ' }) => { + const key = `space_${id}` + return ( + + + + + {children} + + ) +} diff --git a/packages/run-it/src/components/RequestForm/RequestForm.spec.tsx b/packages/run-it/src/components/RequestForm/RequestForm.spec.tsx index 737fce7a2..40d5d665e 100644 --- a/packages/run-it/src/components/RequestForm/RequestForm.spec.tsx +++ b/packages/run-it/src/components/RequestForm/RequestForm.spec.tsx @@ -152,6 +152,57 @@ describe('RequestForm', () => { }) }) + /** Return time that matches day picker in calendar */ + const noon = () => { + const now = new Date() + return new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 12, + 0, + 0, + 0 + ) + } + + test('interacting with a date picker changes the request content', async () => { + const name = 'date_item' + renderWithTheme( + + ) + + const button = screen.getByRole('button', { name: 'Choose' }) + userEvent.click(button) + await waitFor(() => { + const today = noon() + const pickName = today.toDateString() + const cell = screen.getByRole('gridcell', { name: pickName }) + userEvent.click(cell) + expect(setRequestContent).toHaveBeenLastCalledWith({ [name]: today }) + }) + }) + test('interactive with a number simple item changes the request content', async () => { const name = 'number_item' renderWithTheme( diff --git a/packages/run-it/src/components/RequestForm/RequestForm.tsx b/packages/run-it/src/components/RequestForm/RequestForm.tsx index 857b07c9d..6e9fb15b1 100644 --- a/packages/run-it/src/components/RequestForm/RequestForm.tsx +++ b/packages/run-it/src/components/RequestForm/RequestForm.tsx @@ -28,9 +28,9 @@ import React, { BaseSyntheticEvent, FC, Dispatch } from 'react' import { Button, Form, - Space, ButtonTransparent, Tooltip, + Fieldset, } from '@looker/components' import type { IAPIMethods } from '@looker/sdk-rtl' import { RunItHttpMethod, RunItInput, RunItValues } from '../../RunIt' @@ -43,6 +43,7 @@ import { showDataChangeWarning, updateNullableProp, } from './formUtils' +import { FormItem } from './FormItem' /** Properties required by RequestForm */ interface RequestFormProps { @@ -128,48 +129,52 @@ export const RequestForm: FC = ({ return (
- {inputs.map((input) => - typeof input.type === 'string' - ? createSimpleItem( - input, - handleChange, - handleNumberChange, - handleBoolChange, - handleDateChange, - requestContent - ) - : createComplexItem(input, handleComplexChange, requestContent) - )} - {httpMethod !== 'GET' && showDataChangeWarning()} - - - - Clear - - - {hasConfig ? ( - needsAuth ? ( - - ) : ( - - - - ) - ) : ( - !isExtension && - setHasConfig && ( - - - - ) +
+ {inputs.map((input) => + typeof input.type === 'string' + ? createSimpleItem( + input, + handleChange, + handleNumberChange, + handleBoolChange, + handleDateChange, + requestContent + ) + : createComplexItem(input, handleComplexChange, requestContent) )} - + {httpMethod !== 'GET' && showDataChangeWarning()} + + <> + {hasConfig ? ( + needsAuth ? ( + + ) : ( + + + + ) + ) : ( + !isExtension && + setHasConfig && ( + + + + ) + )} + + + Clear + + + + +
) } diff --git a/packages/run-it/src/components/RequestForm/formUtils.spec.tsx b/packages/run-it/src/components/RequestForm/formUtils.spec.tsx index 6f1092109..d1e4024bc 100644 --- a/packages/run-it/src/components/RequestForm/formUtils.spec.tsx +++ b/packages/run-it/src/components/RequestForm/formUtils.spec.tsx @@ -23,6 +23,7 @@ SOFTWARE. */ + import { screen, fireEvent, waitFor } from '@testing-library/react' import { renderWithTheme } from '@looker/components-test-utils' import userEvent from '@testing-library/user-event' @@ -207,9 +208,13 @@ describe('Simple Items', () => { description: 'A simple item of type datetime', }) - test('it creates a datetime item', () => { + test('it creates a datetime item', async () => { renderWithTheme(DateItem) - expect(screen.getByTestId('text-input')).toBeInTheDocument() + const button = screen.getByRole('button', { name: 'Choose' }) + userEvent.click(button) + await waitFor(() => { + expect(screen.getByTestId('text-input')).toBeInTheDocument() + }) }) }) diff --git a/packages/run-it/src/components/RequestForm/formUtils.tsx b/packages/run-it/src/components/RequestForm/formUtils.tsx index d83202ae9..0fbae22b6 100644 --- a/packages/run-it/src/components/RequestForm/formUtils.tsx +++ b/packages/run-it/src/components/RequestForm/formUtils.tsx @@ -25,28 +25,19 @@ */ import React, { BaseSyntheticEvent, Fragment } from 'react' import { - FieldText, - FieldToggleSwitch, + ToggleSwitch, Label, FieldCheckbox, + ButtonOutline, + Box, + Popover, + InputText, } from '@looker/components' -import { InputDate } from '@looker/components-date' +import { DateFormat, InputDate } from '@looker/components-date' import { CodeEditor } from '@looker/code-editor' import { RunItInput, RunItValues } from '../../RunIt' - -const inputTextType = (type: string) => { - switch (type) { - case 'number': - return 'number' - case 'email': - return 'email' - case 'password': - return 'password' - default: - return 'text' - } -} +import { FormItem } from './FormItem' /** * Creates a datetime form item @@ -61,13 +52,33 @@ const createDateItem = ( handleChange: (name: string, date?: Date) => void, requestContent: RunItValues ) => ( -
- - -
+ + + + + } + > + + {name in requestContent ? ( + + {name in requestContent ? requestContent[name] : undefined} + + ) : ( + 'Choose' + )} + + + ) /** @@ -84,19 +95,35 @@ const createBoolItem = ( handleChange: (e: BaseSyntheticEvent) => void, requestContent: RunItValues ) => ( -
- {description && } - -
+ + <> + + {description && } + + ) +const inputTextType = (type: string) => { + switch (type) { + case 'number': + return 'number' + case 'email': + return 'email' + case 'password': + return 'password' + default: + return 'text' + } +} + /** - * + * Create a field text input item based on definitions * @param name Form item's name * @param description Form item's description * @param required Form item's required flag @@ -115,19 +142,18 @@ const createItem = ( handleChange: (e: BaseSyntheticEvent) => void, requestContent: RunItValues ) => ( -
- + -
+ ) /** @@ -219,9 +245,9 @@ export const createComplexItem = ( handleComplexChange: (value: string, name: string) => void, requestContent: RunItValues ) => ( -
- + -
+ ) /** * Creates a required checkbox form item */ export const showDataChangeWarning = () => ( - + + + ) /** diff --git a/packages/run-it/src/components/ShowResponse/responseUtils.tsx b/packages/run-it/src/components/ShowResponse/responseUtils.tsx index 5c16885d5..413b9813a 100644 --- a/packages/run-it/src/components/ShowResponse/responseUtils.tsx +++ b/packages/run-it/src/components/ShowResponse/responseUtils.tsx @@ -27,7 +27,6 @@ import React, { ReactElement } from 'react' import { IRawResponse, ResponseMode, responseMode } from '@looker/sdk-rtl' import { Paragraph, - CodeBlock, MessageBar, TabList, Tab, @@ -35,7 +34,7 @@ import { TabPanel, useTabs, } from '@looker/components' -import { CodeDisplay, Markdown } from '@looker/code-editor' +import { CodeCopy, Markdown } from '@looker/code-editor' import { DataGrid, parseCsv, json2Csv } from '../DataGrid' /** @@ -55,6 +54,12 @@ export const allSimple = (data: any[]) => { return true } +const copyRaw = (code: string, language = 'unknown') => { + return ( + + ) +} + /** * Is every array in this array a "simple" data row? * @param data to check for columnarity @@ -79,7 +84,7 @@ const ShowJSON = (response: IRawResponse) => { const data = json2Csv(content) const showGrid = isColumnar(data.data) const json = JSON.stringify(JSON.parse(response.body), null, 2) - const raw = + const raw = copyRaw(json, 'json') if (showGrid) return return raw } @@ -88,7 +93,7 @@ const ShowJSON = (response: IRawResponse) => { const ShowText = (response: IRawResponse) => ( <> {response.statusMessage !== 'OK' && response.statusMessage} - {response.body.toString()} + {copyRaw(response.body.toString())} ) @@ -97,14 +102,14 @@ const ShowText = (response: IRawResponse) => ( * @param response HTTP response to parse and display */ const ShowCSV = (response: IRawResponse) => { - const raw = {response.body.toString()} + const raw = copyRaw(response.body.toString()) const data = parseCsv(response.body.toString()) return } const ShowMD = (response: IRawResponse) => { const tabs = useTabs() - const raw = {response.body.toString()} + const raw = copyRaw(response.body.toString(), 'markup') const data = response.body.toString() return ( <> @@ -139,13 +144,11 @@ const ShowImage = (response: IRawResponse) => { } /** A handler for HTTP type responses */ -const ShowHTML = (response: IRawResponse) => ( - -) +const ShowHTML = (response: IRawResponse) => + copyRaw(response.body.toString(), 'html') -const ShowSQL = (response: IRawResponse) => ( - -) +const ShowSQL = (response: IRawResponse) => + copyRaw(response.body.toString(), 'sql') /** * A handler for unknown response types. It renders the size of the unknown response and its type. @@ -171,11 +174,7 @@ const ShowRaw = (response: IRawResponse) => ( The response body could not be parsed. Displaying raw data. - + {copyRaw(response?.body?.toString() || '')} )