diff --git a/web-app-frontend/src/components/SchemaFormBuilder.tsx b/web-app-frontend/src/components/SchemaFormBuilder.tsx
index b6b240a..b0e2c31 100644
--- a/web-app-frontend/src/components/SchemaFormBuilder.tsx
+++ b/web-app-frontend/src/components/SchemaFormBuilder.tsx
@@ -5,6 +5,7 @@ import SimpleNode from './builder/SimpleNode';
import ComplexNode from './builder/ComplexNode';
import NodeTypeSelector from './builder/NodeTypeSelector';
import { Form, InputGroup } from 'react-bootstrap';
+import SuggestiveInput from './suggestive-input/SuggestiveInput';
export const initialSchema: SchemaNode = {
nodeType: 'simple',
@@ -28,7 +29,7 @@ const SchemaFormBuilder: React.FC<{
isRoot?: boolean;
}> = ({ node, onChange, rootDefinitions, isRoot = false }) => {
return (
-
+ <>
{isRoot && (
<>
@@ -44,15 +45,16 @@ const SchemaFormBuilder: React.FC<{
Schema
- onChange({ ...node, schema: e.target.value })}
- maxLength={128}
+ onChange={(e) => onChange({ ...node, schema: e.value })}
+ required={true}
+ suggestions={schemas.map(it => {
+ return { key: it, value: it };
+ })}
+ maxSuggestions={5}
/>
-
>
@@ -86,7 +88,7 @@ const SchemaFormBuilder: React.FC<{
onChange={onChange}
/>
)}
-
+ >
);
};
diff --git a/web-app-frontend/src/components/builder/Definitions.tsx b/web-app-frontend/src/components/builder/Definitions.tsx
index 62a3e55..84ce3f2 100644
--- a/web-app-frontend/src/components/builder/Definitions.tsx
+++ b/web-app-frontend/src/components/builder/Definitions.tsx
@@ -1,5 +1,5 @@
import { Accordion, Button, Form, InputGroup } from 'react-bootstrap';
-import React from 'react';
+import React, { useState } from 'react';
import SchemaFormBuilder, { initialSchema } from '../SchemaFormBuilder';
import { SchemaNode } from '../../const/type';
import { LineiconsPlus, LineiconsTrash3 } from '../../const/icons';
@@ -24,49 +24,49 @@ const Definitions: React.FC = ({
onChange,
rootDefinitions
}) => {
+ const [counter, setCounter] = useState(0)
+
return (
Definitions
- {Object.entries(node.definitions || {}).map(([name, defNode], index) => (
-
+ {Object.entries(node.definitions || {}).map(([name, defNode]) => (
+
{name}
-
-
-
- Name
- {
- const newDefinitions = renameField(node.definitions || {}, name, e.target.value)
- onChange({ ...node, definitions: newDefinitions });
- }}
- />
-
-
-
- {
- const newDefinitions = { ...node.definitions };
- newDefinitions[name] = newDefNode;
- onChange({ ...node, definitions: newDefinitions });
- }}
- rootDefinitions={rootDefinitions}
- />
-
+
+
+ Name
+ {
+ const newDefinitions = renameField(node.definitions || {}, name, e.target.value)
+ onChange({ ...node, definitions: newDefinitions });
+ }}
+ />
+
+
+
+ {
+ const newDefinitions = { ...node.definitions };
+ newDefinitions[name] = newDefNode;
+ onChange({ ...node, definitions: newDefinitions });
+ }}
+ rootDefinitions={rootDefinitions}
+ />
))}
@@ -75,7 +75,8 @@ const Definitions: React.FC = ({
variant="outline-success"
size="sm"
onClick={() => {
- const newName = `definition${Object.keys(node.definitions || {}).length + 1}`;
+ const newName = `definition-${counter}`;
+ setCounter(counter + 1)
const newDefinitions = { ...node.definitions || {}, [newName]: initialSchema };
onChange({ ...node, definitions: newDefinitions });
}}
diff --git a/web-app-frontend/src/components/builder/ObjectNode.tsx b/web-app-frontend/src/components/builder/ObjectNode.tsx
index 5357e2a..1c7f6ef 100644
--- a/web-app-frontend/src/components/builder/ObjectNode.tsx
+++ b/web-app-frontend/src/components/builder/ObjectNode.tsx
@@ -1,7 +1,7 @@
import { Accordion, Button, Form, InputGroup } from 'react-bootstrap';
import React from 'react';
import SchemaFormBuilder, { initialSchema } from '../SchemaFormBuilder';
-import { ObjectSchemaNode, SchemaNode } from '../../const/type';
+import { BaseSchemaNode, ObjectSchemaNode, SchemaNode } from '../../const/type';
import { LineiconsPlus, LineiconsTrash3 } from '../../const/icons';
export interface ObjectNodeProps {
@@ -15,6 +15,13 @@ const ObjectNode: React.FC = ({
onChange,
rootDefinitions
}) => {
+ const nodePropertyTypes = (node: BaseSchemaNode): string => {
+ if (node.type === 'undefined') {
+ return node.type;
+ }
+ return node.type.join(', ')
+ }
+
return (
@@ -69,54 +76,52 @@ const ObjectNode: React.FC = ({
- {prop.name || 'Unnamed Property'} ({prop.schema.type})
+ {prop.name || 'Unnamed Property'} ({nodePropertyTypes(prop.schema)})
-
-
-
- Name
-
+
+ Name
+ {
+ const newProperties = [...node.properties!];
+ newProperties[index] = { ...prop, name: e.target.value };
+ onChange({ ...node, properties: newProperties });
+ }}
+ />
+
+ {
const newProperties = [...node.properties!];
- newProperties[index] = { ...prop, name: e.target.value };
+ newProperties[index] = { ...prop, required: e.target.checked };
onChange({ ...node, properties: newProperties });
}}
/>
-
- {
- const newProperties = [...node.properties!];
- newProperties[index] = { ...prop, required: e.target.checked };
- onChange({ ...node, properties: newProperties });
- }}
- />
-
-
-
-
-
{
- const newProperties = [...node.properties!];
- newProperties[index].schema = newSchema;
- onChange({ ...node, properties: newProperties });
- }}
- rootDefinitions={rootDefinitions}
- />
+
+
+
+ {
+ const newProperties = [...node.properties!];
+ newProperties[index].schema = newSchema;
+ onChange({ ...node, properties: newProperties });
+ }}
+ rootDefinitions={rootDefinitions}
+ />
@@ -147,42 +152,40 @@ const ObjectNode: React.FC = ({
- {prop.name || 'Unnamed Property'} ({prop.schema.type})
+ {prop.name || 'Unnamed Property'} ({nodePropertyTypes(prop.schema)})
-
-
-
- Name
- {
- const newProperties = [...node.patternProperties!];
- newProperties[index] = { ...prop, name: e.target.value };
- onChange({ ...node, patternProperties: newProperties });
- }}
- />
-
-
-
-
{
- const newProperties = [...node.patternProperties!];
- newProperties[index].schema = newSchema;
- onChange({ ...node, patternProperties: newProperties });
- }}
- rootDefinitions={rootDefinitions}
- />
+
+
+ Name
+ {
+ const newProperties = [...node.patternProperties!];
+ newProperties[index] = { ...prop, name: e.target.value };
+ onChange({ ...node, patternProperties: newProperties });
+ }}
+ />
+
+
+ {
+ const newProperties = [...node.patternProperties!];
+ newProperties[index].schema = newSchema;
+ onChange({ ...node, patternProperties: newProperties });
+ }}
+ rootDefinitions={rootDefinitions}
+ />
diff --git a/web-app-frontend/src/components/builder/ReferenceNode.tsx b/web-app-frontend/src/components/builder/ReferenceNode.tsx
index 1e6b99a..75ec70d 100644
--- a/web-app-frontend/src/components/builder/ReferenceNode.tsx
+++ b/web-app-frontend/src/components/builder/ReferenceNode.tsx
@@ -1,6 +1,7 @@
import { Form, InputGroup } from 'react-bootstrap';
import React from 'react';
import { SchemaNode } from '../../const/type';
+import SuggestiveInput from '../suggestive-input/SuggestiveInput';
export interface ReferenceNodeProps {
@@ -18,18 +19,20 @@ const ReferenceNode: React.FC = ({
Reference
- onChange({ ...node, reference: e.target.value })}
- />
-
+ maxSuggestions={10}
+ />
)
diff --git a/web-app-frontend/src/components/builder/SimpleNode.tsx b/web-app-frontend/src/components/builder/SimpleNode.tsx
index 6c7b9df..0ba4cab 100644
--- a/web-app-frontend/src/components/builder/SimpleNode.tsx
+++ b/web-app-frontend/src/components/builder/SimpleNode.tsx
@@ -16,6 +16,7 @@ import ConstNode from './ConstNode';
import EnumNode from './EnumNode';
import ReferenceNode from './ReferenceNode';
import Examples from './Examples';
+import MultipleSuggestiveInput from '../suggestive-input/MultipleSuggestiveInput';
export interface SimpleNodeProps {
@@ -24,6 +25,8 @@ export interface SimpleNodeProps {
rootDefinitions?: Record;
}
+const nodeTypes = ['string', 'boolean', 'number', 'integer', 'object', 'array', 'null']
+
const SimpleNode: React.FC = ({
node,
onChange,
@@ -80,26 +83,26 @@ const SimpleNode: React.FC = ({
Type
- {
- if (e.target.selectedOptions.length === 0) {
- node.type = 'undefined';
- return
- }
- const newType: NodeType[] = [];
- for (let selectedOption of e.target.selectedOptions) {
- newType.push(selectedOption.value as NodeType)
- }
- onChange({ ...node, type: newType })
- }
- }
- >
- {['undefined', 'string', 'boolean', 'number', 'integer', 'object', 'array', 'null'].map((type) => (
-
- ))}
-
+ {
+ onChange({ ...node, type: it.map(it => it.key as NodeType) })
+ }}
+ required
+ disabled={node.type === 'undefined'}
+ values={node.type === 'undefined' ? [] : node.type}
+ maxSuggestions={nodeTypes.length}
+ suggestions={nodeTypes.map(it => {
+ return { key: it, value: it }
+ })}
+ />
+
+ Undefined
+
+ onChange({ ...node, type: e.target.checked ? 'undefined' : ['string'] })}
+ />
diff --git a/web-app-frontend/src/components/builder/StringNode.tsx b/web-app-frontend/src/components/builder/StringNode.tsx
index 5240fd3..a56759a 100644
--- a/web-app-frontend/src/components/builder/StringNode.tsx
+++ b/web-app-frontend/src/components/builder/StringNode.tsx
@@ -1,6 +1,7 @@
import { Accordion, Form, InputGroup } from 'react-bootstrap';
import React from 'react';
import { SchemaNode, StringSchemaNode } from '../../const/type';
+import SuggestiveInput from '../suggestive-input/SuggestiveInput';
const buildInFormats = [
'date-time', 'time', 'date', 'duration',
@@ -69,17 +70,16 @@ const StringNode: React.FC = ({
Format
- onChange({ ...node, format: e.target.value })}
+ onChange({ ...node, format: e.value })}
+ required={true}
+ suggestions={buildInFormats.map(it => {
+ return { key: it, value: it };
+ })}
+ maxSuggestions={10}
/>
-
diff --git a/web-app-frontend/src/components/suggestive-input/MultipleSuggestiveInput.tsx b/web-app-frontend/src/components/suggestive-input/MultipleSuggestiveInput.tsx
new file mode 100644
index 0000000..cefc78e
--- /dev/null
+++ b/web-app-frontend/src/components/suggestive-input/MultipleSuggestiveInput.tsx
@@ -0,0 +1,150 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { Form, FormControl, ListGroup } from 'react-bootstrap';
+import './SuggestiveInput.css';
+
+export interface MultipleSuggestiveItem {
+ key: string;
+ value: string;
+ data?: any;
+}
+
+interface MultipleSuggestiveInputProps {
+ id?: string;
+ values?: string[];
+ suggestions: MultipleSuggestiveItem[];
+ maxSuggestions?: number;
+ itemsToScroll?: number;
+ onFilter?: (input: string) => MultipleSuggestiveItem[];
+ onChange: (value: MultipleSuggestiveItem[]) => void;
+ placeholder?: string;
+ required: boolean;
+ disabled?: boolean;
+ clarifyText?: string;
+}
+
+const MultipleSuggestiveInput: React.FC = ({
+ id,
+ values,
+ suggestions,
+ maxSuggestions = 5,
+ itemsToScroll = 5,
+ onFilter,
+ onChange,
+ placeholder,
+ required,
+ disabled,
+ clarifyText = 'Clarify request'
+ }) => {
+ const [inputValue, setInputValue] = useState('');
+ const [selectedValues, setSelectedValues] = useState(new Set(values));
+ const [filteredSuggestions, setFilteredSuggestions] = useState(
+ suggestions.slice(
+ 0,
+ maxSuggestions
+ )
+ );
+ const [filteredSliced, setFilteredSliced] = useState(suggestions.length > filteredSuggestions.length);
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const inputRef = useRef(null);
+ const [dropdownWidth, setDropdownWidth] = useState();
+ const [dropdownLocation, setDropdownLocation] = useState<{ left: number, top: number } | undefined>();
+
+ if (!onFilter) {
+ onFilter = it => {
+ const substring = it.toLowerCase();
+ return suggestions.filter(it => it.value.includes(substring));
+ };
+ }
+
+ useEffect(() => {
+ setSelectedValues(new Set(values));
+ }, [values]);
+
+ useEffect(() => {
+ const filtered = onFilter(inputValue);
+ if (filtered.length === 0) {
+ setFilteredSuggestions(suggestions.slice(0, maxSuggestions));
+ return
+ }
+ const sliced = filtered.slice(0, maxSuggestions);
+ setFilteredSuggestions(sliced)
+ }, [suggestions]);
+
+ useEffect(() => {
+ const inputElement = inputRef.current;
+ if (!inputElement) {
+ return;
+ }
+ setDropdownWidth(inputElement.offsetWidth);
+ setDropdownLocation({ top: inputElement.offsetTop + inputElement.offsetHeight, left: inputElement.offsetLeft })
+ }, [inputRef?.current?.offsetWidth, inputValue]);
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const value = e.target.value;
+ setInputValue(value)
+
+ const filtered = onFilter(value);
+ const sliced = filtered.slice(0, maxSuggestions);
+ setFilteredSuggestions(sliced);
+ setFilteredSliced(filtered.length > sliced.length);
+
+ setShowSuggestions(sliced.length > 0);
+ };
+
+ return (
+ <>
+ setShowSuggestions(filteredSuggestions.length > 0)}
+ onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
+ placeholder={placeholder ?? Array.from(selectedValues).join(", ")}
+ required={required}
+ disabled={disabled}
+ />
+ {showSuggestions && (
+
+
+ {filteredSuggestions.map((suggestion) => (
+ {
+ e.preventDefault();
+ if (selectedValues.has(suggestion.key)) {
+ selectedValues.delete(suggestion.key);
+ } else {
+ setSelectedValues(selectedValues.add(suggestion.key))
+ }
+ onChange(suggestions.filter(it => selectedValues.has(it.key)))
+ }}
+ >
+
+
+ ))}
+ {filteredSliced && (
+
+ {clarifyText}
+
+ )}
+
+
+ )}
+ >
+ );
+};
+
+export default MultipleSuggestiveInput;
diff --git a/web-app-frontend/src/components/suggestive-input/SuggestiveInput.css b/web-app-frontend/src/components/suggestive-input/SuggestiveInput.css
new file mode 100644
index 0000000..3c089f6
--- /dev/null
+++ b/web-app-frontend/src/components/suggestive-input/SuggestiveInput.css
@@ -0,0 +1,23 @@
+.suggestions-dropdown {
+ position: absolute;
+ z-index: 1000;
+ background-color: white;
+ border-radius: 4px;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+ margin-top: 4px;
+}
+
+.suggestion-item {
+ font-size: 0.875rem;
+ padding: 8px 12px;
+ cursor: pointer;
+}
+
+.suggestion-text {
+ font-size: 0.75rem;
+ padding: 8px 12px;
+}
+
+.suggestion-item:hover {
+ background-color: #f8f9fa;
+}
diff --git a/web-app-frontend/src/components/suggestive-input/SuggestiveInput.tsx b/web-app-frontend/src/components/suggestive-input/SuggestiveInput.tsx
new file mode 100644
index 0000000..c2b1ec5
--- /dev/null
+++ b/web-app-frontend/src/components/suggestive-input/SuggestiveInput.tsx
@@ -0,0 +1,169 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { FormControl, ListGroup } from 'react-bootstrap';
+import './SuggestiveInput.css';
+
+export interface SuggestiveItem {
+ key: string;
+ value: string;
+ data?: any;
+}
+
+export interface SuggestedItem {
+ key?: string;
+ value: string;
+ data?: any;
+}
+
+interface SuggestiveInputProps {
+ id?: string;
+ type?: 'text' | 'number'
+ value?: string;
+ suggestions: SuggestiveItem[];
+ maxSuggestions?: number;
+ mode: 'strict' | 'free';
+ itemsToScroll?: number;
+ onFilter?: (input: string) => SuggestiveItem[];
+ onChange: (value: SuggestedItem) => void;
+ placeholder?: string;
+ required: boolean;
+ disabled?: boolean;
+ clarifyText?: string;
+}
+
+const SuggestiveInput: React.FC = ({
+ id,
+ type = 'text',
+ value,
+ suggestions,
+ maxSuggestions = 5,
+ mode,
+ itemsToScroll = 5,
+ onFilter,
+ onChange,
+ placeholder,
+ required,
+ disabled,
+ clarifyText = 'Clarify request'
+ }) => {
+ const [inputValue, setInputValue] = useState(value ?? '');
+ const [filteredSuggestions, setFilteredSuggestions] = useState(
+ suggestions.slice(
+ 0,
+ maxSuggestions
+ )
+ );
+ const [filteredSliced, setFilteredSliced] = useState(suggestions.length > filteredSuggestions.length);
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const inputRef = useRef(null);
+ const [dropdownWidth, setDropdownWidth] = useState();
+ const [dropdownLocation, setDropdownLocation] = useState<{ left: number, top: number } | undefined>();
+
+ if (!onFilter) {
+ onFilter = it => {
+ const substring = it.toLowerCase();
+ return suggestions.filter(it => it.value.includes(substring));
+ };
+ }
+
+ useEffect(() => {
+ const filtered = onFilter(inputValue);
+ if(filtered.length === 0 && mode === 'strict') {
+ setInputValue('')
+ setFilteredSuggestions(suggestions.slice(0, maxSuggestions));
+ return
+ }
+ const sliced = filtered.slice(0, maxSuggestions);
+ setFilteredSuggestions(sliced)
+ }, [suggestions]);
+
+ useEffect(() => {
+ const inputElement = inputRef.current;
+ if (!inputElement) {
+ return;
+ }
+ setDropdownWidth(inputElement.offsetWidth);
+ setDropdownLocation({ top: inputElement.offsetTop + inputElement.offsetHeight, left: inputElement.offsetLeft })
+ }, [inputRef?.current?.offsetWidth, inputValue]);
+
+ const handleValueChange = (value: string): SuggestiveItem[] => {
+ setInputValue(value);
+
+ const filtered = onFilter(value);
+ const sliced = filtered.slice(0, maxSuggestions);
+ setFilteredSuggestions(sliced);
+ setFilteredSliced(filtered.length > sliced.length);
+ return sliced;
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const value = e.target.value;
+ const sliced = handleValueChange(value);
+
+ setShowSuggestions(sliced.length > 0);
+
+ if (sliced.length > 0) {
+ const candidate = sliced[0];
+ onChange({
+ key: candidate.key,
+ value: candidate.value,
+ data: candidate.data
+ });
+ } else if (mode === 'free') {
+ onChange({ value: value });
+ } else {
+ onChange({ value: '' });
+ }
+ };
+
+ const handleSuggestionClick = (suggestion: SuggestiveItem) => {
+ handleValueChange(suggestion.key);
+ setShowSuggestions(false);
+ onChange(suggestion);
+ };
+
+ return (
+ <>
+ setShowSuggestions(filteredSuggestions.length > 0)}
+ onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
+ placeholder={placeholder}
+ required={required}
+ disabled={disabled}
+ />
+ {showSuggestions && (
+
+
+ {filteredSuggestions.map((suggestion) => (
+ handleSuggestionClick(suggestion)}
+ className="suggestion-item"
+ >
+ {suggestion.value}
+
+ ))}
+ {filteredSliced && (
+
+ {clarifyText}
+
+ )}
+
+
+ )}
+ >
+ );
+};
+
+export default SuggestiveInput;
diff --git a/web-app-frontend/src/utils/converter.ts b/web-app-frontend/src/utils/converter.ts
index 35db990..7af9cd2 100644
--- a/web-app-frontend/src/utils/converter.ts
+++ b/web-app-frontend/src/utils/converter.ts
@@ -115,8 +115,9 @@ export function convertToJsonSchema(node: SchemaNode, isRoot: boolean = false):
schema[node.nodeType] = node[node.nodeType]?.map(schemaNode => convertToJsonSchema(schemaNode));
}
- if (isRoot && node.definitions) {
- schema.$defs = Object.entries(node.definitions).reduce((acc, [name, defNode]) => {
+ const definitions = isRoot && node.definitions ? Object.entries(node.definitions) : [];
+ if (definitions && definitions.length > 0) {
+ schema.$defs = definitions.reduce((acc, [name, defNode]) => {
acc[name] = convertToJsonSchema(defNode);
return acc;
}, {} as Record);
@@ -197,9 +198,10 @@ export function parseJsonSchema(json: any): SchemaNode {
baseNode.oneOf = json.oneOf.map((schema: any) => parseJsonSchema(schema));
}
- if (json.$defs) {
+ const defs = Object.entries(json.defs ?? {})
+ if (defs && defs.length > 0) {
const definitions: Record = {};
- for (const [name, defJson] of Object.entries(json.$defs)) {
+ for (const [name, defJson] of defs) {
definitions[name] = parseJsonSchema(defJson);
}
baseNode.definitions = definitions;