diff --git a/src/lib/unstable/kit/components/ArrayRemoveButton/ArrayRemoveButton.tsx b/src/lib/unstable/kit/components/ArrayRemoveButton/ArrayRemoveButton.tsx index d534001a..132cdfdd 100644 --- a/src/lib/unstable/kit/components/ArrayRemoveButton/ArrayRemoveButton.tsx +++ b/src/lib/unstable/kit/components/ArrayRemoveButton/ArrayRemoveButton.tsx @@ -13,6 +13,8 @@ export interface ArrayRemoveButtonProps { export const ArrayRemoveButton: React.FC = ({name}) => { const form = useForm(); + const [ready, setReady] = React.useState(false); + const arrayItem = isArrayItem(name); const tupleItem = isTupleItem(name, form); @@ -29,7 +31,11 @@ export const ArrayRemoveButton: React.FC = ({name}) => { } }, [form, name]); - if (arrayItem && !tupleItem) { + React.useLayoutEffect(() => { + setReady(true); + }, []); + + if (ready && arrayItem && !tupleItem) { return ( + ); + }, [ + controlProps.addButtonText, + controlProps.disabled, + input.onChange, + input.value, + schema.default, + schema.items, + schema.readOnly, + ]); + + const columns = React.useMemo(() => { + const columns: {name: string | undefined; schema: JsonSchema}[] = []; + const itemsSchema = schema.items; + + if (itemsSchema) { + const itemSchema = Array.isArray(itemsSchema) ? itemsSchema[0] : itemsSchema; + + if ( + 'properties' in itemSchema && + itemSchema.properties && + Object.keys(itemSchema.properties).length > 0 + ) { + const properties = itemSchema.properties; + + (controlProps.order || Object.keys(properties)).forEach((columnKey) => { + columns.push({name: columnKey, schema: properties[columnKey] || {}}); + }); + } else { + columns.push({name: undefined, schema: itemSchema}); + } + } + + return columns; + }, [controlProps.order, schema.items]); + + const {head, rows} = React.useMemo(() => { + let rowsCount = input.value?.length; + let withRemoveButton = true; + + if (Array.isArray(schema.items)) { + rowsCount = schema.items.length; + withRemoveButton = false; + } + + const head = ( +
+
+ + # + +
+ {columns.map((column, cIndex) => ( +
+
+ {column.schema.title?.split(' ').map((word, wIndex, array) => ( +
+ {word} + {wIndex + 1 === array.length && column.schema.description ? ( + {column.schema.description} + ) : null} +
+ ))} +
+
+ ))} +
+ ); + + const rows = new Array(rowsCount).fill(null).map((_, rIndex) => ( +
+
+ + {rIndex + 1} + +
+ {columns.map((column, cIndex) => ( +
+ +
+ ))} + {withRemoveButton ? ( +
+ +
+ ) : null} +
+ )); + + return {head, rows}; + }, [ + columns, + controlProps.order, + input.name, + input.onChange, + input.value?.length, + schema.items, + ]); + + return ( + + + + {head} + {rows} + + {addButton} + + + ); +}; + +export const ArrayTable = React.memo(Component); diff --git a/src/lib/unstable/kit/controls/ArrayTable/index.ts b/src/lib/unstable/kit/controls/ArrayTable/index.ts new file mode 100644 index 00000000..816b9458 --- /dev/null +++ b/src/lib/unstable/kit/controls/ArrayTable/index.ts @@ -0,0 +1 @@ +export {ArrayTable, type ArrayTableProps} from './ArrayTable'; diff --git a/src/lib/unstable/kit/controls/DotValue/DotValue.tsx b/src/lib/unstable/kit/controls/DotValue/DotValue.tsx new file mode 100644 index 00000000..256a9829 --- /dev/null +++ b/src/lib/unstable/kit/controls/DotValue/DotValue.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import get from 'lodash/get'; +import isString from 'lodash/isString'; + +import {type Control, Entity, type JsonSchemaObject} from '../../../core'; + +export interface DotValueProps {} + +const Component: Control = ({input, schema}) => { + const childKey = 'value'; + + React.useLayoutEffect(() => { + if (input.value) { + const childValue = get(input.value, childKey); + + if ( + childValue === null || + childValue === undefined || + childValue === '' || + (isString(childValue) && childValue.endsWith('_UNSPECIFIED')) + ) { + input.onChange(undefined); + } + } + }, [input.value]); + + return ( + + ); +}; + +export const DotValue = React.memo(Component); diff --git a/src/lib/unstable/kit/controls/DotValue/index.ts b/src/lib/unstable/kit/controls/DotValue/index.ts new file mode 100644 index 00000000..68b0c336 --- /dev/null +++ b/src/lib/unstable/kit/controls/DotValue/index.ts @@ -0,0 +1 @@ +export {DotValue, type DotValueProps} from './DotValue'; diff --git a/src/lib/unstable/kit/controls/FileInput/FileInput.scss b/src/lib/unstable/kit/controls/FileInput/FileInput.scss new file mode 100644 index 00000000..5cc1ff8b --- /dev/null +++ b/src/lib/unstable/kit/controls/FileInput/FileInput.scss @@ -0,0 +1,5 @@ +@import '../../styles/variables.scss'; + +.#{$ns}file-input { + width: 100%; +} diff --git a/src/lib/unstable/kit/controls/FileInput/FileInput.tsx b/src/lib/unstable/kit/controls/FileInput/FileInput.tsx new file mode 100644 index 00000000..e4c4fbfa --- /dev/null +++ b/src/lib/unstable/kit/controls/FileInput/FileInput.tsx @@ -0,0 +1,93 @@ +import React from 'react'; + +import {Xmark} from '@gravity-ui/icons'; +import {FilePreview, type FilePreviewAction} from '@gravity-ui/uikit'; +import { + unstable_FileDropZone as FileDropZone, + type FileDropZoneProps, +} from '@gravity-ui/uikit/unstable'; + +import type {Control, JsonSchemaString} from '../../../core'; +import {block, getValidationState} from '../../utils'; + +import './FileInput.scss'; + +const b = block('file-input'); + +export interface FileInputProps extends Omit { + readAsMethod?: 'readAsBinaryString' | 'readAsDataURL' | 'readAsText'; +} + +const Component: Control = ({ + controlProps, + input, + meta, + schema, +}) => { + const {readAsMethod = 'readAsBinaryString', ...restControlProps} = controlProps; + + const counterRef = React.useRef(0); + const [file, setFile] = React.useState(null); + + const actions: FilePreviewAction[] = React.useMemo( + () => [ + { + id: 'remove', + title: '', + icon: , + onClick: () => { + input.onChange(undefined); + setFile(null); + }, + tooltipExtraProps: {disabled: true}, + }, + ], + [input.onChange], + ); + + const onUpdate = React.useCallback( + (files: File[]) => { + if (files.length) { + const reader = new FileReader(); + + if (typeof reader[readAsMethod] !== 'function') { + throw new Error(`Unknown parameter: ${readAsMethod}`); + } + + const counter = ++counterRef.current; + + reader.addEventListener('load', () => { + if (counter === counterRef.current) { + input.onFocus(); + input.onChange(reader.result); + input.onBlur(); + setFile(files[0]); + } + }); + + reader[readAsMethod](files[0]); + } + }, + [input.onBlur, input.onChange, input.onFocus, readAsMethod], + ); + + if (file) { + return ; + } + + return ( +
+ +
+ ); +}; + +export const FileInput = React.memo(Component); diff --git a/src/lib/unstable/kit/controls/FileInput/index.ts b/src/lib/unstable/kit/controls/FileInput/index.ts new file mode 100644 index 00000000..b508d027 --- /dev/null +++ b/src/lib/unstable/kit/controls/FileInput/index.ts @@ -0,0 +1 @@ +export {FileInput, type FileInputProps} from './FileInput'; diff --git a/src/lib/unstable/kit/controls/Label/Label.tsx b/src/lib/unstable/kit/controls/Label/Label.tsx new file mode 100644 index 00000000..a1f6c320 --- /dev/null +++ b/src/lib/unstable/kit/controls/Label/Label.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import * as icons from '@gravity-ui/icons'; +import { + Label as GravityLabel, + type LabelProps as GravityLabelProps, + Icon, + type IconProps, +} from '@gravity-ui/uikit'; + +import type {Control, JsonSchemaString} from '../../../core'; + +export interface LabelProps extends GravityLabelProps { + iconName?: keyof typeof icons; + iconProps?: Partial; +} + +const Component: Control = ({controlProps, input, schema}) => { + const {iconName, iconProps, title: titleProp, ...controlRestProps} = controlProps; + + const icon = React.useMemo( + () => + iconName && icons[iconName] ? ( + + ) : undefined, + [iconName, iconProps], + ); + + const content = React.useMemo(() => { + if (titleProp) { + if (typeof titleProp === 'string') { + return ; + } + + return titleProp; + } + + if (schema.description) { + return ; + } + + return undefined; + }, [titleProp, schema.description]); + + return ( + + {content} + + ); +}; + +export const Label = React.memo(Component); diff --git a/src/lib/unstable/kit/controls/Label/index.ts b/src/lib/unstable/kit/controls/Label/index.ts new file mode 100644 index 00000000..0490be3b --- /dev/null +++ b/src/lib/unstable/kit/controls/Label/index.ts @@ -0,0 +1 @@ +export {Label, type LabelProps} from './Label'; diff --git a/src/lib/unstable/kit/controls/TextContent/TextContent.scss b/src/lib/unstable/kit/controls/TextContent/TextContent.scss new file mode 100644 index 00000000..af7aa792 --- /dev/null +++ b/src/lib/unstable/kit/controls/TextContent/TextContent.scss @@ -0,0 +1,16 @@ +@import '../../styles/variables.scss'; + +.#{$ns}text-content { + border: none; + box-shadow: none; + + &.g-label { + &:not(:has(.g-label__addon_side_start)) .g-label__text { + margin-left: 0; + } + + .g-label__separator { + margin-left: 0; + } + } +} diff --git a/src/lib/unstable/kit/controls/TextContent/TextContent.tsx b/src/lib/unstable/kit/controls/TextContent/TextContent.tsx new file mode 100644 index 00000000..43f2d9f1 --- /dev/null +++ b/src/lib/unstable/kit/controls/TextContent/TextContent.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +import * as icons from '@gravity-ui/icons'; +import { + Label as GravityLabel, + type LabelProps as GravityLabelProps, + Icon, + type IconProps, +} from '@gravity-ui/uikit'; + +import type {Control, JsonSchemaString} from '../../../core'; +import {block} from '../../utils'; + +import './TextContent.scss'; + +const b = block('text-content'); + +export interface TextContentProps extends Omit { + iconName?: keyof typeof icons; + iconProps?: Partial; +} + +const Component: Control = ({controlProps, input, schema}) => { + const {iconName, iconProps, title: titleProp, ...controlRestProps} = controlProps; + + const icon = React.useMemo( + () => + iconName && icons[iconName] ? ( + + ) : undefined, + [iconName, iconProps], + ); + + const content = React.useMemo(() => { + if (titleProp) { + if (typeof titleProp === 'string') { + return ; + } + + return titleProp; + } + + if (schema.description) { + return ; + } + + return undefined; + }, [titleProp, schema.description]); + + return ( + + {content} + + ); +}; + +export const TextContent = React.memo(Component); diff --git a/src/lib/unstable/kit/controls/TextContent/index.ts b/src/lib/unstable/kit/controls/TextContent/index.ts new file mode 100644 index 00000000..77e1a291 --- /dev/null +++ b/src/lib/unstable/kit/controls/TextContent/index.ts @@ -0,0 +1 @@ +export {TextContent, type TextContentProps} from './TextContent'; diff --git a/src/lib/unstable/kit/controls/index.ts b/src/lib/unstable/kit/controls/index.ts index 9f4c57fa..c3a53763 100644 --- a/src/lib/unstable/kit/controls/index.ts +++ b/src/lib/unstable/kit/controls/index.ts @@ -1,11 +1,16 @@ +export {Alert, type AlertProps} from './Alert'; export {ArrayBase, type ArrayBaseProps} from './ArrayBase'; +export {ArrayTable, type ArrayTableProps} from './ArrayTable'; export {Checkbox, type CheckboxProps} from './Checkbox'; export {CheckboxGroup, type CheckboxGroupProps} from './CheckboxGroup'; export {ColorPicker, type ColorPickerProps} from './ColorPicker'; export {DateInput, type DateInputProps} from './DateInput'; +export {FileInput, type FileInputProps} from './FileInput'; +export {Label, type LabelProps} from './Label'; export {MultiSelect, type MultiSelectProps} from './MultiSelect'; export {NumberBase, type NumberBaseProps} from './NumberBase'; export {ObjectBase, type ObjectBaseProps} from './ObjectBase'; +export {DotValue, type DotValueProps} from './DotValue'; export {Password, type PasswordProps} from './Password'; export {RadioGroup, type RadioGroupProps} from './RadioGroup'; export {RangeSlider, type RangeSliderProps} from './RangeSlider'; @@ -14,3 +19,4 @@ export {Slider, type SliderProps} from './Slider'; export {StringBase, type StringBaseProps} from './StringBase'; export {Switch, type SwitchProps} from './Switch'; export {TextArea, type TextAreaProps} from './TextArea'; +export {TextContent, type TextContentProps} from './TextContent'; diff --git a/src/lib/unstable/kit/wrappers/Row/Row.scss b/src/lib/unstable/kit/wrappers/Row/Row.scss index 7fe79623..944cf2ad 100644 --- a/src/lib/unstable/kit/wrappers/Row/Row.scss +++ b/src/lib/unstable/kit/wrappers/Row/Row.scss @@ -2,6 +2,7 @@ @import '../../styles/variables.scss'; .#{$ns}row { + width: 100%; margin-bottom: spacing(4); &:last-child { diff --git a/src/lib/unstable/kit/wrappers/Transparent/Transparent.tsx b/src/lib/unstable/kit/wrappers/Transparent/Transparent.tsx index 52a3c025..38e8a803 100644 --- a/src/lib/unstable/kit/wrappers/Transparent/Transparent.tsx +++ b/src/lib/unstable/kit/wrappers/Transparent/Transparent.tsx @@ -12,7 +12,7 @@ const b = block('transparent'); const Component: Wrapper = ({children, input}) => { return ( - + {children} diff --git a/src/stories/Unstable.stories.tsx b/src/stories/Unstable.stories.tsx index be0dcb2c..5ccda5f2 100644 --- a/src/stories/Unstable.stories.tsx +++ b/src/stories/Unstable.stories.tsx @@ -1070,6 +1070,49 @@ const schema: JsonSchemaObject = { controlWrapperType: 'row', }, }, + array_table: { + default: [ + {name: 'John', surname: 'Doe', age: 30}, + {name: 'Jane', surname: 'Smith', age: 25}, + ], + type: JsonSchemaType.Array, + title: 'array_table', + description: 'array table description', + items: { + properties: { + name: { + title: 'name', + description: 'name description', + entityParameters: { + type: EntityType.String, + controlType: 'base', + controlWrapperType: 'transparent', + }, + }, + surname: { + title: 'surname', + entityParameters: { + type: EntityType.String, + controlType: 'base', + controlWrapperType: 'transparent', + }, + }, + age: { + title: 'age', + entityParameters: { + type: EntityType.Number, + controlType: 'base', + controlWrapperType: 'transparent', + }, + }, + }, + }, + entityParameters: { + type: EntityType.Array, + controlType: 'array_table', + controlWrapperType: 'row', + }, + }, number: { type: JsonSchemaType.Number, title: 'number', @@ -1124,6 +1167,55 @@ const schema: JsonSchemaObject = { controlWrapperType: 'row', }, }, + alert: { + type: JsonSchemaType.String, + title: 'alert', + description: 'alert description', + entityParameters: { + type: EntityType.String, + controlType: 'alert', + controlWrapperType: 'row', + controlProps: { + iconName: 'CircleExclamationFill', + iconProps: { + size: 18, + color: 'positive', + }, + message: 'Lorem ipsum dolor sit, amet consectetur adipisicing elit', + title: 'Alert title', + theme: 'info', + }, + }, + }, + label: { + default: 'label value', + type: JsonSchemaType.String, + title: 'label', + description: 'label description', + entityParameters: { + type: EntityType.String, + controlType: 'label', + controlWrapperType: 'row', + controlProps: { + iconName: 'TriangleExclamation', + title: 'Label title', + theme: 'clear', + }, + }, + }, + text_content: { + type: JsonSchemaType.String, + title: 'text_content', + description: 'text content description', + entityParameters: { + type: EntityType.String, + controlType: 'text_content', + controlWrapperType: 'row', + controlProps: { + iconName: 'TriangleExclamation', + }, + }, + }, password: { type: JsonSchemaType.String, title: 'password', @@ -1275,6 +1367,40 @@ const schema: JsonSchemaObject = { controlWrapperType: 'row', }, }, + object_value: { + type: JsonSchemaType.Object, + title: 'object_value', + description: 'object value description', + properties: { + value: { + type: JsonSchemaType.String, + title: 'Value', + entityParameters: { + type: EntityType.String, + controlType: 'base', + controlWrapperType: 'transparent', + }, + }, + }, + entityParameters: { + type: EntityType.Object, + controlType: 'dot_value', + controlWrapperType: 'row', + }, + }, + file: { + type: JsonSchemaType.String, + title: 'File Input', + entityParameters: { + type: EntityType.String, + controlType: 'file', + controlWrapperType: 'row', + controlProps: { + accept: ['.json', '.txt'], + readAsMethod: 'readAsText', + }, + }, + }, }, entityParameters: { type: EntityType.Object, @@ -1289,6 +1415,8 @@ const value = { number: 123, string: 'test', boolean: true, + object_value: {value: 'test'}, + text_content: 'value', }, };