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} /> - - {schemas.map(it => @@ -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 })} - /> - - { + onChange={(e) => onChange({ ...node, reference: e.value })} + required={true} + suggestions={ Object.keys(rootDefinitions || {}) .map(it => `#/$defs/${it}`) - .map(it => + 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} /> - - { - buildInFormats - .map(it => 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;