Feature/option to upload file or text in multipart form (#6534)

* added option to multipart form data for file or text upload

* updated styles for the dynamic-text-field-with-dropdown styled component

* added cypress tests for multipart body type

* code refactor: moved constants to ApiEditorConstants.ts

* updated multipart dropdown styles

* minor bug fix
This commit is contained in:
Favour Ohanekwu 2021-08-16 13:00:11 +01:00 committed by GitHub
parent 30c11e4a74
commit f0c7ce0e89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 353 additions and 108 deletions

View File

@ -43,6 +43,7 @@
"next": "?page=2&pageSize=10",
"prev": "?page=1&pageSize=10",
"apiFormDataBodyType": "x-www-form-urlencoded",
"apiMultipartBodyType": "multi-part",
"defaultMoustacheData": "{{Input1.text",
"defaultInputWidget": "{{Table1.selectedRow.id",
"deafultDropDownWidget": [

View File

@ -0,0 +1,20 @@
const testdata = require("../../../../fixtures/testdata.json");
const apiEditor = require("../../../../locators/ApiEditor.json");
const apiwidget = require("../../../../locators/apiWidgetslocator.json");
describe("API Panel request body", function() {
it("Check whether input and type dropdown selector exist when multi-part is selected", function() {
cy.NavigateToAPI_Panel();
cy.CreateAPI("FirstAPI");
cy.SelectAction(testdata.postAction);
cy.contains(apiEditor.bodyTab).click();
cy.contains(testdata.apiFormDataBodyType).click();
cy.contains(testdata.apiMultipartBodyType).click();
cy.get(apiwidget.formEncoded).should("be.visible");
cy.get(apiwidget.multipartTypeDropdown).should("be.visible");
cy.DeleteAPI();
});
});

View File

@ -55,5 +55,6 @@
"renameEntity": ".single-select >div:contains('Edit Name')",
"paramsTab": "//li//span[text()='Params']",
"paramKey": ".t--actionConfiguration\\.queryParameters\\[0\\]\\.key\\.0",
"paramValue": ".t--actionConfiguration\\.queryParameters\\[0\\]\\.value\\.0"
"paramValue": ".t--actionConfiguration\\.queryParameters\\[0\\]\\.value\\.0",
"multipartTypeDropdown":"button:contains('Text')"
}

View File

@ -21,7 +21,25 @@ import { Toaster } from "components/ads/Toast";
import ReCAPTCHA from "react-google-recaptcha";
const getButtonColorStyles = (props: { theme: Theme } & ButtonStyleProps) => {
if (props.filled) return props.theme.colors.textOnDarkBG;
if (props.filled) {
return props.accent === "grey"
? props.theme.colors.textOnGreyBG
: props.theme.colors.textOnDarkBG;
}
if (props.accent) {
if (props.accent === "secondary") {
return props.theme.colors[AccentColorMap["primary"]];
}
return props.theme.colors[AccentColorMap[props.accent]];
}
};
const getButtonFillStyles = (props: { theme: Theme } & ButtonStyleProps) => {
if (props.filled) {
return props.accent === "grey"
? props.theme.colors.dropdownIconDarkBg
: props.theme.colors.textOnDarkBG;
}
if (props.accent) {
if (props.accent === "secondary") {
return props.theme.colors[AccentColorMap["primary"]];
@ -33,7 +51,7 @@ const getButtonColorStyles = (props: { theme: Theme } & ButtonStyleProps) => {
const ButtonColorStyles = css<ButtonStyleProps>`
color: ${getButtonColorStyles};
svg {
fill: ${getButtonColorStyles};
fill: ${getButtonFillStyles};
}
`;
@ -48,6 +66,7 @@ const AccentColorMap: Record<ButtonStyleName, string> = {
primary: "primaryOld",
secondary: "secondaryOld",
error: "error",
grey: "dropdownGreyBg",
};
const ButtonWrapper = styled((props: ButtonStyleProps & IButtonProps) => (
@ -67,9 +86,14 @@ const ButtonWrapper = styled((props: ButtonStyleProps & IButtonProps) => (
props.accent
? props.theme.colors[AccentColorMap[props.accent]]
: props.theme.colors.primary};
color: ${(props) =>
props.accent === "grey"
? props.theme.colors.textOnGreyBG
: props.theme.colors.textOnDarkBG};
border-radius: 0;
font-weight: ${(props) => props.theme.fontWeights[2]};
outline: none;
&.bp3-button {
padding: 0px 10px;
}
@ -80,10 +104,10 @@ const ButtonWrapper = styled((props: ButtonStyleProps & IButtonProps) => (
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
max-height: 100%;
overflow: hidden;
}
&&:hover,
&&:focus {
${ButtonColorStyles};
@ -122,7 +146,7 @@ const ButtonWrapper = styled((props: ButtonStyleProps & IButtonProps) => (
}
`;
export type ButtonStyleName = "primary" | "secondary" | "error";
export type ButtonStyleName = "primary" | "secondary" | "error" | "grey";
type ButtonStyleProps = {
accent?: ButtonStyleName;

View File

@ -1,7 +1,16 @@
import React, { Component, ReactNode } from "react";
import styled from "styled-components";
import { MenuItem, Menu, ControlGroup, InputGroup } from "@blueprintjs/core";
import { BaseButton } from "components/designSystems/blueprint/ButtonComponent";
import {
MenuItem,
Menu,
ControlGroup,
InputGroup,
IMenuProps,
} from "@blueprintjs/core";
import {
BaseButton,
ButtonStyleName,
} from "components/designSystems/blueprint/ButtonComponent";
import {
ItemRenderer,
Select,
@ -9,11 +18,40 @@ import {
IItemListRendererProps,
} from "@blueprintjs/select";
import { DropdownOption } from "widgets/DropdownWidget";
import { WrappedFieldInputProps } from "redux-form";
interface ButtonWrapperProps {
width?: string;
}
interface MenuProps {
width?: string;
}
type MenuComponentProps = IMenuProps & MenuProps;
const Dropdown = Select.ofType<DropdownOption>();
const StyledDropdown = styled(Dropdown)``;
const StyledButtonWrapper = styled.div<ButtonWrapperProps>`
width: ${(props) => props.width || "100%"};
`;
const StyledMenu = styled(Menu)<MenuComponentProps>`
min-width: ${(props) => props.width || "100%"};
border-radius: 0;
`;
const StyledMenuItem = styled(MenuItem)`
border-radius: 0;
&&&.bp3-active {
background: ${(props) => props.theme.colors.propertyPane.activeButtonText};
}
`;
class DropdownComponent extends Component<DropdownComponentProps> {
componentDidMount() {
const { input, selected } = this.props;
input && input.onChange(selected?.value);
}
private newItemTextInput: HTMLInputElement | null = null;
private setNewItemTextInput = (element: HTMLInputElement | null) => {
this.newItemTextInput = element;
@ -41,36 +79,35 @@ class DropdownComponent extends Component<DropdownComponentProps> {
renderItemList: ItemListRenderer<DropdownOption> = (
props: IItemListRendererProps<DropdownOption>,
) => {
if (this.props.addItem) {
const renderItems = props.items.map(props.renderItem).filter(Boolean);
const displayMode = (
<BaseButton
accent="primary"
filled
icon-right="plus"
onClick={this.showTextBox}
text={this.props.addItem.displayText}
/>
);
const editMode = (
<ControlGroup fill>
<InputGroup inputRef={this.setNewItemTextInput} />
<BaseButton
filled
onClick={this.handleAddItem}
text={this.props.addItem.displayText}
/>
</ControlGroup>
);
return (
<Menu ulRef={props.itemsParentRef}>
{renderItems}
{!this.state.isEditing ? displayMode : editMode}
</Menu>
);
}
const { items, renderItem } = props;
const { addItem, width } = this.props;
const renderItems = items.map(renderItem).filter(Boolean);
return null;
const displayMode = (
<BaseButton
accent="primary"
filled
icon-right="plus"
onClick={this.showTextBox}
text={addItem?.displayText}
/>
);
const editMode = (
<ControlGroup fill>
<InputGroup inputRef={this.setNewItemTextInput} />
<BaseButton
filled
onClick={this.handleAddItem}
text={addItem?.displayText}
/>
</ControlGroup>
);
return (
<StyledMenu ulRef={props.itemsParentRef} width={width}>
{renderItems}
{addItem && (!this.state.isEditing ? displayMode : editMode)}
</StyledMenu>
);
};
searchItem = (query: string, option: DropdownOption): boolean => {
@ -82,6 +119,7 @@ class DropdownComponent extends Component<DropdownComponentProps> {
);
};
onItemSelect = (item: DropdownOption): void => {
this.props.input?.onChange(item.value);
this.props.selectHandler(item.value);
};
@ -93,13 +131,13 @@ class DropdownComponent extends Component<DropdownComponentProps> {
return null;
}
return (
<MenuItem
<StyledMenuItem
active={modifiers.active}
key={option.value}
label={option.label ? option.label : ""}
label={this.props.hasLabel ? option.label : ""}
onClick={handleClick}
shouldDismissPopover={false}
text={option.label || option.label}
text={option.label}
/>
);
};
@ -110,31 +148,45 @@ class DropdownComponent extends Component<DropdownComponentProps> {
(option) => option.value === selectedValue,
);
return item && (item.label || item.label);
return item && item.label;
}
return "";
};
render() {
const {
accent,
autocomplete,
filled,
input,
options,
selected,
width,
} = this.props;
return (
<StyledDropdown
activeItem={this.props.selected}
filterable={!!this.props.autocomplete}
itemListRenderer={this.props.addItem && this.renderItemList}
activeItem={selected}
filterable={!!autocomplete}
itemListRenderer={this.renderItemList}
itemPredicate={this.searchItem}
itemRenderer={this.renderItem}
items={this.props.options}
items={options}
itemsEqual="value"
noResults={<MenuItem disabled text="No results." />}
onItemSelect={this.onItemSelect}
popoverProps={{ minimal: true }}
{...input}
>
{this.props.toggle || (
<BaseButton
accent="secondary"
rightIcon="chevron-down"
text={this.getSelectedDisplayText()}
/>
<StyledButtonWrapper width={width}>
<BaseButton
accent={accent || "secondary"}
filled={!!filled}
rightIcon="chevron-down"
text={this.getSelectedDisplayText()}
/>
</StyledButtonWrapper>
)}
</StyledDropdown>
);
@ -142,6 +194,7 @@ class DropdownComponent extends Component<DropdownComponentProps> {
}
export interface DropdownComponentProps {
hasLabel?: boolean;
options: DropdownOption[];
selectHandler: (selectedValue: string) => void;
selected?: DropdownOption;
@ -154,6 +207,10 @@ export interface DropdownComponentProps {
addItemHandler: (name: string) => void;
};
toggle?: ReactNode;
accent?: ButtonStyleName;
filled?: boolean;
input?: WrappedFieldInputProps;
width?: string;
}
export default DropdownComponent;

View File

@ -0,0 +1,50 @@
import React from "react";
import { Field, BaseFieldProps } from "redux-form";
import DropdownComponent from "components/editorComponents/DropdownComponent";
import { ButtonStyleName } from "components/designSystems/blueprint/ButtonComponent";
import { DropdownOption } from "widgets/DropdownWidget";
interface DynamicDropdownFieldOptions {
options: DropdownOption[];
accent?: ButtonStyleName;
filled?: boolean;
width?: string;
}
type DynamicDropdownFieldProps = BaseFieldProps & DynamicDropdownFieldOptions;
class DynamicDropdownField extends React.Component<
DynamicDropdownFieldProps,
{
selectedOption: DropdownOption;
}
> {
constructor(props: DynamicDropdownFieldProps) {
super(props);
this.state = {
selectedOption: this.props.options[0],
};
}
handleOptionSelection = (selectedValue: string): void => {
const selectedOption = this.props.options.find(
(option) => option.value === selectedValue,
) as DropdownOption;
this.setState({
selectedOption,
});
};
render() {
const dropdownProps = {
selectHandler: this.handleOptionSelection,
selected: this.state.selectedOption,
};
return (
<Field component={DropdownComponent} {...this.props} {...dropdownProps} />
);
}
}
export default DynamicDropdownField;

View File

@ -30,6 +30,7 @@ class DynamicTextField extends React.Component<
theme: this.props.theme || EditorTheme.LIGHT,
size: this.props.size || EditorSize.COMPACT,
};
return <Field component={CodeEditor} {...this.props} {...editorProps} />;
}
}

View File

@ -14,6 +14,12 @@ import {
import Text, { Case, TextType } from "components/ads/Text";
import { Classes } from "components/ads/common";
import { AutocompleteDataType } from "utils/autocomplete/TernServer";
import DynamicDropdownField from "./DynamicDropdownField";
import { Colors } from "constants/Colors";
import {
DEFAULT_MULTI_PART_DROPDOWN_WIDTH,
MULTI_PART_DROPDOWN_OPTIONS,
} from "constants/ApiEditorConstants";
type CustomStack = {
removeTopPadding?: boolean;
@ -83,6 +89,23 @@ const FlexContainer = styled.div`
}
`;
const DynamicTextFieldWithDropdownWrapper = styled.div`
display: flex;
position: relative;
border-bottom: solid 1px ${Colors.MERCURY};
margin-bottom: 10px;
top: -2px;
& .CodeEditorTarget * {
border-bottom: none !important;
}
`;
const DynamicDropdownFieldWrapper = styled.div`
position: relative;
top: 1px;
margin-left: 5px;
`;
const expected = {
type: FIELD_VALUES.API_ACTION.params,
example: "",
@ -136,16 +159,41 @@ function KeyValueRow(props: Props & WrappedFieldArrayProps) {
return (
<FormRowWithLabel key={index}>
<Flex size={1}>
<DynamicTextField
border={CodeEditorBorder.BOTTOM_SIDE}
className={`t--${field}.key.${index}`}
dataTreePath={`${props.dataTreePath}[${index}].key`}
expected={expected}
hoverInteraction
name={`${field}.key`}
placeholder={`Key ${index + 1}`}
theme={props.theme}
/>
{props.hasType ? (
<DynamicTextFieldWithDropdownWrapper>
<DynamicTextField
border={CodeEditorBorder.BOTTOM_SIDE}
className={`t--${field}.key.${index}`}
dataTreePath={`${props.dataTreePath}[${index}].key`}
expected={expected}
hoverInteraction
name={`${field}.key`}
placeholder={`Key ${index + 1}`}
theme={props.theme}
/>
<DynamicDropdownFieldWrapper>
<DynamicDropdownField
accent="grey"
filled
name={`${field}.type`}
options={MULTI_PART_DROPDOWN_OPTIONS}
width={DEFAULT_MULTI_PART_DROPDOWN_WIDTH}
/>
</DynamicDropdownFieldWrapper>
</DynamicTextFieldWithDropdownWrapper>
) : (
<DynamicTextField
border={CodeEditorBorder.BOTTOM_SIDE}
className={`t--${field}.key.${index}`}
dataTreePath={`${props.dataTreePath}[${index}].key`}
expected={expected}
hoverInteraction
name={`${field}.key`}
placeholder={`Key ${index + 1}`}
theme={props.theme}
/>
)}
</Flex>
{!props.actionConfig && (
@ -239,6 +287,7 @@ type Props = {
dataTreePath?: string;
hideHeader?: boolean;
theme?: EditorTheme;
hasType?: boolean;
};
function KeyValueFieldArray(props: Props) {

View File

@ -48,7 +48,7 @@ export const CONTENT_TYPE_HEADER_KEY = "content-type";
export enum ApiContentTypes {
JSON = "json",
FORM_URLENCODED = "x-www-form-urlencoded",
MULTIPART_FORM_DATA = "form-data",
MULTIPART_FORM_DATA = "multi-part",
RAW = "raw",
}
@ -74,3 +74,26 @@ export const POST_BODY_FORMAT_TITLES = POST_BODY_FORMAT_OPTIONS.map(
return { title: option.label, key: option.value };
},
);
export enum MultiPartOptionTypes {
TEXT = "Text",
FILE = "File",
}
export interface MULTI_PART_DROPDOWN_OPTION {
label: MultiPartOptionTypes;
value: string;
}
export const MULTI_PART_DROPDOWN_OPTIONS: MULTI_PART_DROPDOWN_OPTION[] = [
{
label: MultiPartOptionTypes.TEXT,
value: "TEXT",
},
{
label: MultiPartOptionTypes.FILE,
value: "FILE",
},
];
export const DEFAULT_MULTI_PART_DROPDOWN_WIDTH = "75px";

View File

@ -2706,6 +2706,7 @@ export const theme: Theme = {
inputInactiveBG: Colors.AQUA_HAZE,
textDefault: Colors.BLACK_PEARL,
textOnDarkBG: Colors.WHITE,
textOnGreyBG: Colors.CHARCOAL,
textAnchor: Colors.PURPLE,
border: Colors.GEYSER,
paneCard: Colors.SHARK,
@ -2748,6 +2749,8 @@ export const theme: Theme = {
dropdownIconBg: Colors.ALTO2,
welcomeTourStickySidebarColor: Colors.WHITE,
welcomeTourStickySidebarBackground: "#F86A2B",
dropdownIconDarkBg: Colors.DARK_GRAY,
dropdownGreyBg: Colors.Gallery,
},
lineHeights: [0, 14, 16, 18, 22, 24, 28, 36, 48, 64, 80],

View File

@ -63,6 +63,65 @@ function PostBodyData(props: Props) {
updateBodyContentType,
} = props;
const tabComponentsMap = (
key: string,
contentType: ApiContentTypes,
): JSX.Element => {
return {
[ApiContentTypes.JSON]: (
<JSONEditorFieldWrapper className={"t--apiFormPostBody"} key={key}>
<DynamicTextField
dataTreePath={`${dataTreePath}.body`}
expected={expectedPostBody}
mode={EditorModes.JSON_WITH_BINDING}
name="actionConfiguration.body"
placeholder={
'{\n "name":"{{ inputName.property }}",\n "preference":"{{ dropdownName.property }}"\n}\n\n\\\\Take widget inputs using {{ }}'
}
showLineNumbers
size={EditorSize.EXTENDED}
tabBehaviour={TabBehaviour.INDENT}
theme={theme}
/>
</JSONEditorFieldWrapper>
),
[ApiContentTypes.FORM_URLENCODED]: (
<KeyValueFieldArray
dataTreePath={`${dataTreePath}.bodyFormData`}
key={key}
label=""
name="actionConfiguration.bodyFormData"
theme={theme}
/>
),
[ApiContentTypes.MULTIPART_FORM_DATA]: (
<KeyValueFieldArray
dataTreePath={`${dataTreePath}.bodyFormData`}
hasType
key={key}
label=""
name="actionConfiguration.bodyFormData"
pushFields
theme={theme}
/>
),
[ApiContentTypes.RAW]: (
<JSONEditorFieldWrapper key={key}>
<DynamicTextField
dataTreePath={`${dataTreePath}.body`}
mode={EditorModes.TEXT_WITH_BINDING}
name="actionConfiguration.body"
size={EditorSize.EXTENDED}
tabBehaviour={TabBehaviour.INDENT}
theme={theme}
/>
</JSONEditorFieldWrapper>
),
}[contentType];
};
return (
<PostBodyContainer>
<MultiSwitch
@ -71,54 +130,11 @@ function PostBodyData(props: Props) {
}
selected={displayFormat}
tabs={POST_BODY_FORMAT_TITLES.map((el) => {
let component = (
<JSONEditorFieldWrapper
className={"t--apiFormPostBody"}
key={el.key}
>
<DynamicTextField
dataTreePath={`${dataTreePath}.body`}
expected={expectedPostBody}
mode={EditorModes.JSON_WITH_BINDING}
name="actionConfiguration.body"
placeholder={
'{\n "name":"{{ inputName.property }}",\n "preference":"{{ dropdownName.property }}"\n}\n\n\\\\Take widget inputs using {{ }}'
}
showLineNumbers
size={EditorSize.EXTENDED}
tabBehaviour={TabBehaviour.INDENT}
theme={theme}
/>
</JSONEditorFieldWrapper>
);
if (
el.title === ApiContentTypes.FORM_URLENCODED ||
el.title === ApiContentTypes.MULTIPART_FORM_DATA
) {
component = (
<KeyValueFieldArray
dataTreePath={`${dataTreePath}.bodyFormData`}
key={el.key}
label=""
name="actionConfiguration.bodyFormData"
theme={theme}
/>
);
} else if (el.title === ApiContentTypes.RAW) {
component = (
<JSONEditorFieldWrapper key={el.key}>
<DynamicTextField
dataTreePath={`${dataTreePath}.body`}
mode={EditorModes.TEXT_WITH_BINDING}
name="actionConfiguration.body"
size={EditorSize.EXTENDED}
tabBehaviour={TabBehaviour.INDENT}
theme={theme}
/>
</JSONEditorFieldWrapper>
);
}
return { key: el.key, title: el.title, panelComponent: component };
return {
key: el.key,
title: el.title,
panelComponent: tabComponentsMap(el.key, el.title),
};
})}
/>
</PostBodyContainer>