Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mock-server): improve headers UI #1171

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from "react";
import { DeleteOutlined } from "@ant-design/icons";
import { AutoComplete, Button, Col, Input, Row } from "antd";
import HEADER_SUGGESTIONS from "config/constants/sub/header-suggestions";
import { useMediaQuery } from "react-responsive";
import "../index.css";
interface HeaderError {
errorIndex: number;
typeOfError: "name" | "value";
description?: string;
}
interface ValidationErrors {
headers: HeaderError[];
}
interface HeaderSectionProps {
mappedHeader: {
name: string;
value: string;
index: number;
}[];
errors: ValidationErrors;
setMappedHeader: (headers: { name: string; value: string; index: number }[]) => void;
setHeadersString: (headersString: string) => void;
}
export default function HeaderSection({ mappedHeader, errors, setMappedHeader, setHeadersString }: HeaderSectionProps) {
const [localErrors, setLocalErrors] = useState<ValidationErrors>(errors);
const [filteredOptions, setFilteredOptions] = useState(HEADER_SUGGESTIONS.Response);
const isSmallScreen = !useMediaQuery({ query: "(max-width: 768px)" });

const addHeader = () => {
const newHeader = { name: "", value: "", index: mappedHeader.length };
setMappedHeader([...mappedHeader, newHeader]);
};

const removeHeader = useCallback(
(index: number) => {
const updatedHeaders = mappedHeader.filter((_, i) => i !== index);
setMappedHeader(updatedHeaders);
setHeadersString(JSON.stringify(updatedHeaders));
const updatedErrors = localErrors.headers.filter((error) => error.errorIndex !== index);
const reIndexedErrors = updatedErrors.map((error) => {
if (error.errorIndex > index) {
return { ...error, errorIndex: error.errorIndex - 1 };
}
return error;
});
setLocalErrors({ headers: reIndexedErrors });
},
[mappedHeader, localErrors, setMappedHeader, setHeadersString]
);

const updateHeaders = useCallback(
(value: string, index: number, type: "name" | "value") => {
let updatedHeaders = [...mappedHeader];
updatedHeaders = updatedHeaders.map((header, i) => {
if (i === index) {
return { ...header, [type]: value };
}
return header;
});
// Checks if the updated header is the last one and if it has some value
if (index === mappedHeader.length - 1 && (updatedHeaders[index].name || updatedHeaders[index].value)) {
updatedHeaders.push({ name: "", value: "", index: updatedHeaders.length });
}
setMappedHeader(updatedHeaders);
setHeadersString(JSON.stringify(updatedHeaders));
},
[mappedHeader, setMappedHeader, setHeadersString]
);

const renderHeaderErrors = (index: number, typeOfError: "name" | "value") => {
if (!localErrors.headers) return null;
return localErrors.headers
.filter((err) => err.errorIndex === index && err.typeOfError === typeOfError)
.map((err, errorIndex) => (
<div key={errorIndex} className="field-error-prompt">
{err.description}
</div>
));
};
useEffect(() => {
setLocalErrors(errors);
}, [errors]);
console.log("errors : ", errors.headers);
return (
<Col span={24}>
<div className="header-section">
<Button onClick={addHeader} className="add-header">
<span className="add-header-span">+</span>
{mappedHeader.length === 0 ? "Add Headers" : "Add Modification"}
</Button>
{mappedHeader.map((header, index) => (
<Row key={header.index} gutter={20} className="header-row">
<Col span={10}>
<AutoComplete
popupClassName="scrollable-dropdown"
options={filteredOptions}
value={header.name}
onChange={(value) => updateHeaders(value, index, "name")}
onSearch={(inputValue) => {
const lowerInputValue = inputValue.toLowerCase();
const filtered = HEADER_SUGGESTIONS.Response.filter((option) =>
option.value.toLowerCase().includes(lowerInputValue)
);
setFilteredOptions(filtered);
}}
>
<Input
placeholder="Header Name"
addonBefore={!isSmallScreen ? null : "Request Header"}
status={
localErrors.headers &&
localErrors.headers.some((err) => err.errorIndex === index && err.typeOfError === "name")
? "error"
: undefined
}
onChange={(e) => updateHeaders(e.target.value, index, "name")}
/>
</AutoComplete>
{renderHeaderErrors(index, "name")}
</Col>
<Col span={9}>
<Input
placeholder="Header Value"
addonBefore={!isSmallScreen ? null : "Value"}
value={header.value}
onChange={(e) => updateHeaders(e.target.value, index, "value")}
status={
localErrors.headers &&
localErrors.headers.some((err) => err.errorIndex === index && err.typeOfError === "value")
? "error"
: undefined
}
/>
{renderHeaderErrors(index, "value")}
</Col>
<Col span={1}>
<Button onClick={() => removeHeader(index)} icon={<DeleteOutlined />} />
</Col>
</Row>
))}
</div>
</Col>
);
}

// Default props
HeaderSection.defaultProps = {
errors: { headers: [] },
};
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,30 @@
.generate-ai-response-button {
margin-top: 0.5rem;
}

.add-header {
margin-bottom: 16px;
border: 1px dashed #3e3f42;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
}

.header-section {
margin-bottom: 16px;
}

.header-row {
margin-bottom: 4px;
}

.error-message {
color: red;
}

.add-header-span {
margin-right: 8px;
font-weight: bold;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { TabsProps } from "antd";
import { generateFinalUrl } from "../../utils";
import { requestMethodDropdownOptions } from "../constants";
import { MockEditorDataSchema, RequestMethod, ValidationErrors } from "../types";
import { cleanupEndpoint, getEditorLanguage, validateEndpoint, validateStatusCode } from "../utils";
import { cleanupEndpoint, getEditorLanguage, validateEndpoint, validateStatusCode, validateHeaders } from "../utils";
import "./index.css";
import {
trackAiResponseButtonClicked,
Expand All @@ -32,6 +32,7 @@ import { APIClient, APIClientRequest } from "components/common/APIClient";
import MockEditorEndpoint from "./Endpoint";
import { trackRQDesktopLastActivity, trackRQLastActivity } from "utils/AnalyticsUtils";
import { MOCKSV2 } from "modules/analytics/events/features/constants";
import HeaderSection from "./HeaderSection";

interface Props {
isNew?: boolean;
Expand All @@ -43,6 +44,12 @@ interface Props {
mockType?: MockType;
}

interface Header {
name: string;
value: string;
index: number;
}

const MockEditor: React.FC<Props> = ({
isNew = false,
isEditorReadOnly = false,
Expand Down Expand Up @@ -70,13 +77,15 @@ const MockEditor: React.FC<Props> = ({
const [contentType, setContentType] = useState<string>(mockData.contentType);
const [endpoint, setEndpoint] = useState<string>(mockData.endpoint);
const [headersString, setHeadersString] = useState<string>(JSON.stringify(mockData.headers));
const [mappedHeader, setMappedHeader] = useState<Header[]>([]);
const [body, setBody] = useState<string>(mockData.body);

const [fileType] = useState<FileType>(mockData?.fileType || null);
const [errors, setErrors] = useState<ValidationErrors>({
name: null,
statusCode: null,
endpoint: null,
headers: [],
});

const [isAiResponseModalOpen, setIsAiResponseModalOpen] = useState(false);
Expand Down Expand Up @@ -104,6 +113,23 @@ const MockEditor: React.FC<Props> = ({
trackMockEditorOpened(mockType);
}, [mockType]);

useEffect(() => {
try {
const headersObject = JSON.parse(headersString);
const headersArray: Header[] = Object.values(headersObject);
const mappedHeaders = headersArray.map((header, index) => {
return {
name: header.name,
value: header.value,
index: index,
};
});
setMappedHeader(mappedHeaders);
} catch (error) {
console.error(error);
}
}, [headersString]);

const handleMockLatencyChange = (value: number) => {
if (Number.isInteger(value)) {
return setLatency(value);
Expand Down Expand Up @@ -155,6 +181,19 @@ const MockEditor: React.FC<Props> = ({
updatedErrors.statusCode = statusCodeValidationError;
if (!focusedInvalidFieldRef) focusedInvalidFieldRef = statusCodeRef;
}
const headersToValidate = mappedHeader.map((header) => ({
name: header.index.toString(),
value: { name: header.name, value: header.value },
}));
const headerErrors = validateHeaders(headersToValidate);

if (headerErrors.length > 0) {
updatedErrors.headers = headerErrors.map((error) => ({
description: error.description,
errorIndex: error.errorIndex,
typeOfError: error.typeOfError,
}));
}

// TODO: Add more validations here for special characters, //, etc.
const endpointValidationError = validateEndpoint(data.endpoint);
Expand Down Expand Up @@ -321,20 +360,15 @@ const MockEditor: React.FC<Props> = ({
return null;
}
return (
<Row className="editor-row">
<Col span={24}>
{/* @ts-ignore */}
<CodeEditor
height={220}
language="json"
value={headersString}
readOnly={false}
handleChange={setHeadersString}
/>
</Col>
</Row>
<HeaderSection
mappedHeader={mappedHeader}
//@ts-ignore
errors={errors}
setMappedHeader={setMappedHeader}
setHeadersString={setHeadersString}
/>
);
}, [headersString, type]);
}, [type, mappedHeader, errors]);

const renderBodyRow = useCallback((): ReactNode => {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface ValidationErrors {
name?: string;
statusCode?: string;
endpoint?: string;
headers?: { errorIndex: number; typeOfError: "name" | "value"; description?: string }[];
}

// TODO: Remove this. Fetch this from @requestly/mock-server or APP_CONSTANTS
Expand Down
35 changes: 35 additions & 0 deletions app/src/components/features/mocksV2/MockEditorIndex/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import HEADER_SUGGESTIONS from "config/constants/sub/header-suggestions";
import { FileType } from "../types";
interface HeaderError {
typeOfError: "name" | "value";
description: string;
errorIndex: number;
}

// Remove leading & trailing slash
export const cleanupEndpoint = (endpoint: string) => {
Expand Down Expand Up @@ -48,3 +54,32 @@ export const getEditorLanguage = (fileType?: FileType) => {
return "json";
}
};

export const validateHeaders = (
headerItems: Array<{ name: string; value: { name: string; value: string } }>
): HeaderError[] => {
const headerErrors: HeaderError[] = [];
const seenHeaderNames = new Set<string>();

headerItems.forEach((headerWrapper, index) => {
const headerName = headerWrapper.value.name;
const headerValue = headerWrapper.value.value;

if (!headerName) {
headerErrors.push({ typeOfError: "name", description: "Header name is required", errorIndex: index });
} else if (!HEADER_SUGGESTIONS.Response.some((option) => option.value.toLowerCase() === headerName.toLowerCase())) {
headerErrors.push({ typeOfError: "name", description: "Invalid Header name", errorIndex: index });
} else if (seenHeaderNames.has(headerName.toLowerCase())) {
headerErrors.push({ typeOfError: "name", description: "Duplicate Header name", errorIndex: index });
} else {
seenHeaderNames.add(headerName.toLowerCase());
}

if (!headerValue) {
headerErrors.push({ typeOfError: "value", description: "Header value is required", errorIndex: index });
} else if (!/^[a-zA-Z\d/]*$/.test(headerValue)) {
headerErrors.push({ typeOfError: "value", description: "Invalid characters in Header value", errorIndex: index });
}
});
return headerErrors;
};