diff --git a/src/components/UrlBuilder/Readme.md b/src/components/UrlBuilder/Readme.md new file mode 100644 index 00000000..c3fd7ccf --- /dev/null +++ b/src/components/UrlBuilder/Readme.md @@ -0,0 +1,36 @@ +# URL Builder + +```jsx +const handleChange = (value) => { + console.log('URL changed:', value); +}; + + +``` \ No newline at end of file diff --git a/src/components/UrlBuilder/URLPart.js b/src/components/UrlBuilder/URLPart.js new file mode 100644 index 00000000..baaae5f6 --- /dev/null +++ b/src/components/UrlBuilder/URLPart.js @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import URLPartActions from './URLPartActions'; +import URLPartDropdown from './URLPartDropdown'; + +const URLPart = ({ + type, + value, + options, + isEditing, + isFocused, + isDisabled, + onEdit, + onChange, + onClear, + onPartClick, + onStartEditing, + onFinishEditing, + inputRef, + placeholder +}) => { + const [customInput, setCustomInput] = useState(''); + const isPlaceholder = !value || value === ''; + + const getDisplayValue = () => { + if (!value) { + return placeholder; + } + if (type === 'path') { + return value ? `/${value}` : placeholder; + } + return value; + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + handleCustomInput(); + } + }; + + const handleCustomInput = () => { + if (customInput.trim()) { + onChange(type, customInput.trim()); + setCustomInput(''); + } + onFinishEditing(); + }; + + const handleInputChange = (e) => { + const newValue = e.target.value; + setCustomInput(newValue); + onEdit(e, type); + }; + + const handleInputBlur = () => { + handleCustomInput(); + }; + + return ( +
+ {isEditing && type !== 'method' ? ( + e.stopPropagation()} + disabled={isDisabled} + /> + ) : ( +
onPartClick(type, e)} + onDoubleClick={(e) => type !== 'method' && onStartEditing(e, type)} + > + {getDisplayValue()} +
+ )} + + + + +
+ ); +}; + +URLPart.propTypes = { + type: PropTypes.string.isRequired, + value: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.string), + isEditing: PropTypes.bool, + isFocused: PropTypes.bool, + isDisabled: PropTypes.bool, + onEdit: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onPartClick: PropTypes.func.isRequired, + onStartEditing: PropTypes.func.isRequired, + onFinishEditing: PropTypes.func.isRequired, + inputRef: PropTypes.object, + placeholder: PropTypes.string.isRequired +}; + +export default URLPart; \ No newline at end of file diff --git a/src/components/UrlBuilder/URLPartActions.js b/src/components/UrlBuilder/URLPartActions.js new file mode 100644 index 00000000..50b9ca08 --- /dev/null +++ b/src/components/UrlBuilder/URLPartActions.js @@ -0,0 +1,48 @@ +import React from "react"; +import PropTypes from "prop-types"; +import Icon from "../Icon"; + +const URLPartActions = ({ + type, + isEditing, + isPlaceholder, + isDisabled, + onEdit, + onClear, +}) => { + if (isEditing || isPlaceholder || isDisabled || type === "method") { + return null; + } + + return ( +
+ + +
+ ); +}; + +URLPartActions.propTypes = { + type: PropTypes.string.isRequired, + isEditing: PropTypes.bool, + isPlaceholder: PropTypes.bool, + isDisabled: PropTypes.bool, + onEdit: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, +}; + +export default URLPartActions; diff --git a/src/components/UrlBuilder/URLPartDropdown.js b/src/components/UrlBuilder/URLPartDropdown.js new file mode 100644 index 00000000..1dcc9ea7 --- /dev/null +++ b/src/components/UrlBuilder/URLPartDropdown.js @@ -0,0 +1,78 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from '../Icon'; + +const URLPartDropdown = ({ + type, + value, + options, + isVisible, + onChange, + onStartEditing +}) => { + if (!isVisible) { + return null; + } + + const handleOptionSelect = (option) => { + onChange(type, option); + }; + + return ( +
e.stopPropagation()} + > +
+ {options.length > 0 ? ( + <>Select {type} or add custom + ) : ( + <>Type to customize + )} +
+ {type !== 'method' && ( +
onStartEditing(e, type)} + > + + Add custom value +
+ )} + {options.length > 0 && ( + <> +
handleOptionSelect('')} + > + Clear selection +
+ {options.map(option => ( +
handleOptionSelect(option)} + > + {option} +
+ ))} + + )} +
+ ); +}; + +URLPartDropdown.propTypes = { + type: PropTypes.string.isRequired, + value: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.string), + isVisible: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onStartEditing: PropTypes.func.isRequired +}; + +export default URLPartDropdown; \ No newline at end of file diff --git a/src/components/UrlBuilder/index.js b/src/components/UrlBuilder/index.js new file mode 100644 index 00000000..3c6f0e59 --- /dev/null +++ b/src/components/UrlBuilder/index.js @@ -0,0 +1,207 @@ +import React, { useState, useEffect, useRef, useMemo } from "react"; +import PropTypes from "prop-types"; +import URLPart from "./URLPart"; +import "./url-builder.css"; + +const DEFAULT_OPTIONS = { + methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], + hosts: [], + listenPaths: [], + endpoints: [], + paths: [], +}; + +const PLACEHOLDERS = { + method: "Select Method", + host: "Enter API URL", + listenPath: "Enter API version or base path", + endpoint: "Enter endpoint or resource path", + path: "Enter ID or parameter", +}; + +const URLBuilder = ({ + initialValue = {}, + options = {}, + onChange, + disabled = false, + error, +}) => { + const mergedOptions = useMemo( + () => ({ + methods: options.methods || DEFAULT_OPTIONS.methods, + hosts: options.hosts || DEFAULT_OPTIONS.hosts, + listenPaths: options.listenPaths || DEFAULT_OPTIONS.listenPaths, + endpoints: options.endpoints || DEFAULT_OPTIONS.endpoints, + paths: options.paths || DEFAULT_OPTIONS.paths, + }), + [options] + ); + + const [selectedParts, setSelectedParts] = useState({ + method: initialValue.method || "", + host: initialValue.host || "", + listenPath: initialValue.listenPath || "", + endpoint: initialValue.endpoint || "", + path: initialValue.path || "", + }); + + const [focusedPart, setFocusedPart] = useState(null); + const [editingPart, setEditingPart] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current && editingPart) { + inputRef.current.focus(); + } + }, [editingPart]); + + useEffect(() => { + if (onChange) { + onChange(selectedParts); + } + }, [selectedParts, onChange]); + + const handleEdit = (e, type) => { + e.stopPropagation(); + const value = e.target.value; + setSelectedParts((prev) => ({ + ...prev, + [type]: type === "path" ? value.replace(/^\//, "") : value, + })); + }; + + const startEditing = (e, type) => { + if (disabled) return; + + e.stopPropagation(); + setEditingPart(type); + setFocusedPart(null); + }; + + const handlePartClick = (type, e) => { + if (disabled) return; + e.stopPropagation(); + + if (editingPart === type) { + return; + } + + setFocusedPart(type); + setEditingPart(null); + }; + + const finishEditing = () => { + setEditingPart(null); + }; + + const handlePartChange = (part, value) => { + const processedValue = part === "path" ? value.replace(/^\//, "") : value; + setSelectedParts((prev) => ({ + ...prev, + [part]: processedValue, + })); + setFocusedPart(null); + setEditingPart(null); + }; + + const clearSelection = (e, type) => { + if (disabled) return; + e.stopPropagation(); + setSelectedParts((prev) => ({ + ...prev, + [type]: "", + })); + }; + + const urlParts = useMemo( + () => [ + { + type: "method", + value: selectedParts.method, + options: mergedOptions.methods, + }, + { type: "host", value: selectedParts.host, options: mergedOptions.hosts }, + { + type: "listenPath", + value: selectedParts.listenPath, + options: mergedOptions.listenPaths, + }, + { + type: "endpoint", + value: selectedParts.endpoint, + options: mergedOptions.endpoints, + }, + { type: "path", value: selectedParts.path, options: mergedOptions.paths }, + ], + [selectedParts, mergedOptions] + ); + + return ( +
{ + setFocusedPart(null); + setEditingPart(null); + }} + > +
+
+ {urlParts.map(({ type, value, options }) => ( + + ))} +
+
+ {error &&
{error}
} +
+ ); +}; + +URLBuilder.propTypes = { + initialValue: PropTypes.shape({ + method: PropTypes.string, + host: PropTypes.string, + listenPath: PropTypes.string, + endpoint: PropTypes.string, + path: PropTypes.string, + }), + options: PropTypes.shape({ + methods: PropTypes.arrayOf(PropTypes.string), + hosts: PropTypes.arrayOf(PropTypes.string), + listenPaths: PropTypes.arrayOf(PropTypes.string), + endpoints: PropTypes.arrayOf(PropTypes.string), + paths: PropTypes.arrayOf(PropTypes.string), + }), + onChange: PropTypes.func, + disabled: PropTypes.bool, + error: PropTypes.string, +}; + +URLBuilder.defaultProps = { + initialValue: {}, + options: {}, + onChange: null, + disabled: false, + error: null, +}; + +export default React.memo(URLBuilder); diff --git a/src/components/UrlBuilder/url-builder.css b/src/components/UrlBuilder/url-builder.css new file mode 100644 index 00000000..359ae464 --- /dev/null +++ b/src/components/UrlBuilder/url-builder.css @@ -0,0 +1,305 @@ +.url-builder { + width: 100%; + margin: 0 auto; + position: relative; + font-family: var(--font-family); + color: var(--color-text-base); +} + +.url-builder__container { + position: relative; + width: 100%; + padding: var(--spacing-sm); + background: white; + border: var(--general-border-width) solid var(--color-secondary-dark); + border-radius: var(--general-border-radius); +} + +.url-builder__container--error { + border-color: var(--color-danger-base); +} + +.url-builder__parts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-xs); +} + +.url-builder__part { + position: relative; + flex-shrink: 0; + border-radius: 8px; + transition: all 0.15s ease; +} + +.url-builder__part--method { + display: flex; + align-items: center; + background: var(--color-secondary-base); + color: var(--color-text-base); + font-family: var(--font-family-semi-bold); + padding: 1px; + border-radius: 6px; + margin-right: var(--spacing-xs); + position: relative; +} + +.url-builder__part--method::after { + content: ''; + position: absolute; + right: -6px; + top: 50%; + transform: translateY(-50%); + width: 1px; + height: 16px; + background: var(--color-secondary-dark); +} + +.url-builder__part--method .url-builder__value { + display: flex; + align-items: center; + background: var(--color-secondary-extra-light); + border-radius: 6px; + padding: 3px var(--spacing-sm); + min-width: 60px; + text-align: center; +} + +.url-builder__part--method .url-builder__value:hover { + background: var(--color-secondary-light); +} + +.url-builder__part--host { + background: var(--color-info-extra-light); + color: var(--color-info-dark); +} + +.url-builder__part--listenpath { + background: var(--color-primary-extra-light); + color: var(--color-primary-dark); +} + +.url-builder__part--endpoint { + background: var(--color-success-extra-light); + color: var(--color-success-dark); +} + +.url-builder__part--path { + background: var(--color-warning-extra-light); + color: var(--color-warning-dark); +} + +.url-builder__value { + padding: 6px 12px; + cursor: pointer; + border-radius: 8px; + font-size: var(--xs-font-size); + line-height: var(--sm-line-height); + transition: all 0.15s ease; + position: relative; +} + +.url-builder__value:hover { + filter: brightness(0.95); +} + +.url-builder__part--placeholder .url-builder__value { + color: var(--color-text-light); + font-style: italic; + border: 1px dashed var(--color-secondary-dark); + background-color: white; + opacity: 0.8; + padding: 6px 24px 6px 12px; +} + +.url-builder__part--placeholder .url-builder__value::after { + content: '✎'; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 11px; + opacity: 0.5; + font-style: normal; +} + +.url-builder__input { + padding: 6px 12px; + border: var(--general-border-width) solid var(--color-secondary-dark); + border-radius: 8px; + font-size: var(--xs-font-size); + line-height: var(--sm-line-height); + outline: none; + background: white; + color: var(--color-text-base); + min-width: 100px; + transition: all 0.15s ease; +} + +.url-builder__input:focus { + border-color: var(--color-primary-base); + box-shadow: 0 0 0 2px var(--color-primary-extra-light); +} + +.url-builder__input::placeholder { + color: var(--color-text-light); + font-style: italic; +} + +.url-builder__part--focused { + box-shadow: 0 0 0 2px var(--color-primary-extra-light); +} + +.url-builder__part--disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.url-builder__part--disabled .url-builder__value { + cursor: not-allowed; +} + +.url-builder__actions { + position: absolute; + top: -3px; + right: -3px; + display: none; + gap: 3px; + z-index: 20; +} + +.url-builder__part:hover .url-builder__actions { + display: flex; +} + +.url-builder__button { + padding: var(--spacing-xs); + background: white; + border: var(--general-border-width) solid var(--color-secondary-dark); + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-light); + width: 24px; + height: 24px; +} + +.url-builder__button:hover { + background: var(--color-secondary-light); + border-color: var(--color-secondary-extra-dark); + color: var(--color-text-base); +} + +.url-builder__button--clear:hover { + background: var(--color-danger-extra-light); + border-color: var(--color-danger-base); + color: var(--color-danger-base); +} + +.url-builder__button .tyk-icon { + font-size: 12px; +} + +.url-builder__dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 150px; + background: white; + border: var(--general-border-width) solid var(--color-secondary-dark); + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + z-index: 1000; + max-height: 250px; + overflow-y: auto; + animation: dropdownFadeIn 0.2s ease; +} + +.url-builder__dropdown--method { + min-width: 100px; +} + +@keyframes dropdownFadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.url-builder__dropdown-header { + padding: 8px 12px; + font-size: var(--xs-font-size); + color: var(--color-text-light); + border-bottom: 1px solid var(--color-secondary-base); + font-style: italic; +} + +.url-builder__dropdown-clear { + padding: 8px 12px; + cursor: pointer; + color: var(--color-danger-base); + border-bottom: 1px solid var(--color-secondary-base); + transition: all 0.15s ease; + font-size: var(--xs-font-size); +} + +.url-builder__dropdown-clear:hover { + background: var(--color-danger-extra-light); +} + +.url-builder__dropdown-item { + padding: 8px 12px; + cursor: pointer; + transition: all 0.15s ease; + font-size: var(--xs-font-size); +} + +.url-builder__dropdown-item:hover { + background: var(--color-secondary-light); +} + +.url-builder__dropdown-item--selected { + background: var(--color-primary-extra-light); + color: var(--color-primary-base); +} + +.url-builder__dropdown-item--selected:hover { + background: var(--color-primary-light); +} + +.url-builder__error { + margin-top: var(--spacing-xs); + padding: 0 var(--spacing-xs); + color: var(--color-danger-base); + font-size: var(--xs-font-size); + line-height: var(--sm-line-height); +} + +.url-builder__dropdown-custom { + padding: 8px 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + color: var(--color-primary-base); + border-bottom: 1px solid var(--color-secondary-base); + transition: all 0.15s ease; + font-size: var(--xs-font-size); +} + +.url-builder__dropdown-custom:hover { + background: var(--color-primary-extra-light); +} + +.url-builder__dropdown-icon { + font-size: 12px; +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index 431f43bb..9139b60d 100644 --- a/src/index.js +++ b/src/index.js @@ -29,6 +29,7 @@ export { default as toast } from './components/Toast'; export { default as Tooltip } from './components/Tooltip'; export { default as Table } from './components/Table'; export { default as Stepper } from './components/Stepper'; +export { default as UrlBuilder } from './components/UrlBuilder'; // -- Layout export { default as Column } from './layout/Column';