From b3117ca758af14d2e139bcc6ee49f2fa0b354481 Mon Sep 17 00:00:00 2001 From: Victor Trumpel Date: Wed, 14 Oct 2026 17:45:28 +0300 Subject: [PATCH 1/3] feat: init ParameterWizard --- .../DataTypePopup/DataTypePopup.scss | 25 ++ .../DataTypePopup/DataTypePopup.stories.tsx | 96 +++++ .../DataTypePopup/DataTypePopup.tsx | 41 ++ src/components/DataTypePopup/README.md | 24 ++ src/components/DataTypePopup/i18n/dicts.ts | 4 + src/components/DataTypePopup/i18n/en.json | 4 + src/components/DataTypePopup/i18n/index.ts | 4 + src/components/DataTypePopup/i18n/ru.json | 4 + src/components/DataTypePopup/index.ts | 2 + .../DataTypePopup/internal/DataTypeTree.scss | 24 ++ .../DataTypePopup/internal/DataTypeTree.tsx | 109 ++++++ src/components/MonacoEditor/MonacoEditor.tsx | 17 +- src/components/index.ts | 2 + src/helpers/dataTypes.ts | 15 + src/index.ts | 3 + .../ParameterWizard/ParameterWizard.scss | 4 + .../ParameterWizard.stories.tsx | 366 ++++++++++++++++++ .../ParameterWizard/ParameterWizard.tsx | 98 +++++ src/modules/ParameterWizard/README.md | 103 +++++ src/modules/ParameterWizard/helpers/binary.ts | 49 +++ src/modules/ParameterWizard/helpers/date.ts | 106 +++++ .../ParameterWizard/helpers/validateValue.ts | 149 +++++++ src/modules/ParameterWizard/helpers/values.ts | 86 ++++ src/modules/ParameterWizard/i18n/dicts.ts | 4 + src/modules/ParameterWizard/i18n/en.json | 25 ++ src/modules/ParameterWizard/i18n/index.ts | 5 + src/modules/ParameterWizard/i18n/ru.json | 25 ++ src/modules/ParameterWizard/index.ts | 8 + .../internal/ContainerEditor.scss | 3 + .../internal/ContainerEditor.tsx | 214 ++++++++++ .../ParameterWizard/internal/DraftInput.tsx | 46 +++ .../internal/OptionalValueControl.tsx | 54 +++ .../internal/ParameterEditor.scss | 46 +++ .../internal/ParameterEditor.tsx | 203 ++++++++++ .../internal/PrimitiveEditor.tsx | 134 +++++++ .../ParameterWizard/internal/RawEditor.scss | 19 + .../ParameterWizard/internal/RawEditor.tsx | 61 +++ src/modules/ParameterWizard/internal/types.ts | 13 + .../ParameterWizard/internal/useDraftValue.ts | 32 ++ .../ParameterWizard/internal/useItemKeys.ts | 23 ++ .../ParameterWizard/internal/validation.ts | 12 + src/modules/index.ts | 2 + src/types/dataTypePopup.ts | 9 + src/types/parameterWizard.ts | 90 +++++ 44 files changed, 2362 insertions(+), 1 deletion(-) create mode 100644 src/components/DataTypePopup/DataTypePopup.scss create mode 100644 src/components/DataTypePopup/DataTypePopup.stories.tsx create mode 100644 src/components/DataTypePopup/DataTypePopup.tsx create mode 100644 src/components/DataTypePopup/README.md create mode 100644 src/components/DataTypePopup/i18n/dicts.ts create mode 100644 src/components/DataTypePopup/i18n/en.json create mode 100644 src/components/DataTypePopup/i18n/index.ts create mode 100644 src/components/DataTypePopup/i18n/ru.json create mode 100644 src/components/DataTypePopup/index.ts create mode 100644 src/components/DataTypePopup/internal/DataTypeTree.scss create mode 100644 src/components/DataTypePopup/internal/DataTypeTree.tsx create mode 100644 src/helpers/dataTypes.ts create mode 100644 src/modules/ParameterWizard/ParameterWizard.scss create mode 100644 src/modules/ParameterWizard/ParameterWizard.stories.tsx create mode 100644 src/modules/ParameterWizard/ParameterWizard.tsx create mode 100644 src/modules/ParameterWizard/README.md create mode 100644 src/modules/ParameterWizard/helpers/binary.ts create mode 100644 src/modules/ParameterWizard/helpers/date.ts create mode 100644 src/modules/ParameterWizard/helpers/validateValue.ts create mode 100644 src/modules/ParameterWizard/helpers/values.ts create mode 100644 src/modules/ParameterWizard/i18n/dicts.ts create mode 100644 src/modules/ParameterWizard/i18n/en.json create mode 100644 src/modules/ParameterWizard/i18n/index.ts create mode 100644 src/modules/ParameterWizard/i18n/ru.json create mode 100644 src/modules/ParameterWizard/index.ts create mode 100644 src/modules/ParameterWizard/internal/ContainerEditor.scss create mode 100644 src/modules/ParameterWizard/internal/ContainerEditor.tsx create mode 100644 src/modules/ParameterWizard/internal/DraftInput.tsx create mode 100644 src/modules/ParameterWizard/internal/OptionalValueControl.tsx create mode 100644 src/modules/ParameterWizard/internal/ParameterEditor.scss create mode 100644 src/modules/ParameterWizard/internal/ParameterEditor.tsx create mode 100644 src/modules/ParameterWizard/internal/PrimitiveEditor.tsx create mode 100644 src/modules/ParameterWizard/internal/RawEditor.scss create mode 100644 src/modules/ParameterWizard/internal/RawEditor.tsx create mode 100644 src/modules/ParameterWizard/internal/types.ts create mode 100644 src/modules/ParameterWizard/internal/useDraftValue.ts create mode 100644 src/modules/ParameterWizard/internal/useItemKeys.ts create mode 100644 src/modules/ParameterWizard/internal/validation.ts create mode 100644 src/types/dataTypePopup.ts create mode 100644 src/types/parameterWizard.ts diff --git a/src/components/DataTypePopup/DataTypePopup.scss b/src/components/DataTypePopup/DataTypePopup.scss new file mode 100644 index 0000000..3291306 --- /dev/null +++ b/src/components/DataTypePopup/DataTypePopup.scss @@ -0,0 +1,25 @@ +.qp-data-type-popup { + min-width: 0; + + &_interactive { + cursor: pointer; + } + border-radius: var(--g-border-radius-xs); + + &:focus-visible { + outline: 2px solid var(--g-color-line-focus); + outline-offset: 2px; + } + + &__icon { + flex-shrink: 0; + color: var(--g-color-text-hint); + } + + &__content { + max-height: 300px; + max-width: min(600px, calc(100vw - 32px)); + overflow: auto; + padding: var(--g-spacing-3); + } +} diff --git a/src/components/DataTypePopup/DataTypePopup.stories.tsx b/src/components/DataTypePopup/DataTypePopup.stories.tsx new file mode 100644 index 0000000..99b0d73 --- /dev/null +++ b/src/components/DataTypePopup/DataTypePopup.stories.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {ThemeProvider} from '@gravity-ui/uikit'; +import {DataTypePopup} from './DataTypePopup'; +import type {ParameterType} from '../../types/parameterWizard'; + +const nestedType: ParameterType = { + name: 'Struct', + struct: [ + {key: 'id', type: {name: 'Uint64'}}, + {key: 'name', type: {name: 'Utf8', optional: true}}, + { + key: 'items', + type: { + name: 'List', + type: { + name: 'Struct', + struct: [ + {key: 'price', type: {name: 'Decimal', params: [22, 9]}}, + {key: 'tags', type: {name: 'Set', type: {name: 'String'}}}, + ], + }, + }, + }, + ], +}; + +const meta = { + title: 'Components/DataTypePopup', + component: DataTypePopup, + tags: ['autodocs'], + parameters: {layout: 'centered'}, + args: {children: 'Uint64', type: {name: 'Uint64'}}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primitive: Story = {}; +export const Nested: Story = {args: {children: 'Struct', type: nestedType}}; +export const OptionalAndTagged: Story = { + args: { + children: 'Decimal', + type: { + name: 'Decimal', + params: [22, 9], + optional: true, + optionalLevel: 3, + tagged: true, + tags: ['Money', 'USD'], + }, + }, +}; +export const Dictionary: Story = { + args: { + children: 'Dict', + type: {name: 'Dict', type: [{name: 'Utf8'}, {name: 'List', type: {name: 'Int64'}}]}, + }, +}; +export const Variant: Story = { + args: { + children: 'Variant', + type: { + name: 'Variant', + struct: [ + {key: 'text', type: {name: 'Utf8'}}, + {key: 'point', type: {name: 'Tuple', type: [{name: 'Double'}, {name: 'Double'}]}}, + {key: 'status', type: {name: 'Enum', type: [{name: 'ready'}, {name: 'pending'}]}}, + ], + }, + }, +}; +export const WithoutIcon: Story = {args: {...Nested.args, hideIcon: true}}; +export const WithoutType: Story = {args: {children: 'No type information', type: undefined}}; +export const LongStructure: Story = { + args: { + children: 'Struct with 50 fields', + type: { + name: 'Struct', + struct: Array.from({length: 50}, (_, index) => ({ + key: `field_${index}`, + type: {name: 'Utf8'}, + })), + }, + }, +}; +export const DarkTheme: Story = { + args: Nested.args, + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/src/components/DataTypePopup/DataTypePopup.tsx b/src/components/DataTypePopup/DataTypePopup.tsx new file mode 100644 index 0000000..23522a9 --- /dev/null +++ b/src/components/DataTypePopup/DataTypePopup.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {CircleInfo} from '@gravity-ui/icons'; +import {Flex, Icon, Popover} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import type {DataTypePopupProps} from '../../types/dataTypePopup'; +import {formatType} from '../../helpers/dataTypes'; +import {DataTypeTree} from './internal/DataTypeTree'; +import './DataTypePopup.scss'; + +const block = cn('qp-data-type-popup'); + +export function DataTypePopup({children, type, hideIcon, className}: DataTypePopupProps) { + if (!type) { + return {children}; + } + return ( + + + + } + > + + {children} + {!hideIcon && } + + + ); +} diff --git a/src/components/DataTypePopup/README.md b/src/components/DataTypePopup/README.md new file mode 100644 index 0000000..7ba4090 --- /dev/null +++ b/src/components/DataTypePopup/README.md @@ -0,0 +1,24 @@ +# DataTypePopup + +Displays a parsed data type in an interactive popover. It uses the same +`ParameterType` descriptor as ParameterWizard and has no dependencies on the YQL +application or its type parser. + +```tsx +import {DataTypePopup} from '@gravity-ui/querieskit'; + +List<Uint64>; +``` + +- `type`: optional type descriptor. Without it, children render without a popup. +- `children`: visible trigger content. +- `hideIcon`: hides the information icon. +- `className`: styles the trigger. + +The popover supports hover, click and keyboard activation. Container types form +an expandable tree; the first two levels start expanded. Struct/Variant fields, +Enum members, Decimal parameters, Optional depth and tags are displayed. Content +scrolls when it exceeds 300px in height. + +See **Components / DataTypePopup** in Storybook. ParameterWizard uses this component +for its visible type labels. diff --git a/src/components/DataTypePopup/i18n/dicts.ts b/src/components/DataTypePopup/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/components/DataTypePopup/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/components/DataTypePopup/i18n/en.json b/src/components/DataTypePopup/i18n/en.json new file mode 100644 index 0000000..1a63e55 --- /dev/null +++ b/src/components/DataTypePopup/i18n/en.json @@ -0,0 +1,4 @@ +{ + "action_expand": "Expand {{type}}", + "action_collapse": "Collapse {{type}}" +} diff --git a/src/components/DataTypePopup/i18n/index.ts b/src/components/DataTypePopup/i18n/index.ts new file mode 100644 index 0000000..58abaa6 --- /dev/null +++ b/src/components/DataTypePopup/i18n/index.ts @@ -0,0 +1,4 @@ +import {addI18Keysets} from '../../../i18n'; +import dicts from './dicts'; + +export default addI18Keysets('qp:data-type-popup', dicts); diff --git a/src/components/DataTypePopup/i18n/ru.json b/src/components/DataTypePopup/i18n/ru.json new file mode 100644 index 0000000..b8ff96b --- /dev/null +++ b/src/components/DataTypePopup/i18n/ru.json @@ -0,0 +1,4 @@ +{ + "action_expand": "Развернуть {{type}}", + "action_collapse": "Свернуть {{type}}" +} diff --git a/src/components/DataTypePopup/index.ts b/src/components/DataTypePopup/index.ts new file mode 100644 index 0000000..89d39d3 --- /dev/null +++ b/src/components/DataTypePopup/index.ts @@ -0,0 +1,2 @@ +export {DataTypePopup} from './DataTypePopup'; +export type {DataTypePopupProps} from '../../types/dataTypePopup'; diff --git a/src/components/DataTypePopup/internal/DataTypeTree.scss b/src/components/DataTypePopup/internal/DataTypeTree.scss new file mode 100644 index 0000000..5eea17b --- /dev/null +++ b/src/components/DataTypePopup/internal/DataTypeTree.scss @@ -0,0 +1,24 @@ +.qp-data-type-tree { + min-width: 0; + overflow-wrap: anywhere; + + &__toggle { + --g-button-padding: 0; + font-family: var(--g-font-family-monospace); + font-weight: 600; + --g-button-text-color: var(--g-color-text-link); + } + + &__optional { + font-size: var(--g-text-code-1-font-size); + font-style: italic; + } + + &__children { + padding-inline-start: var(--g-spacing-4); + } + + &__expand { + align-self: flex-start; + } +} diff --git a/src/components/DataTypePopup/internal/DataTypeTree.tsx b/src/components/DataTypePopup/internal/DataTypeTree.tsx new file mode 100644 index 0000000..102b179 --- /dev/null +++ b/src/components/DataTypePopup/internal/DataTypeTree.tsx @@ -0,0 +1,109 @@ +import React, {useState} from 'react'; +import {Button, Flex, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import type {ParameterType} from '../../../types/parameterWizard'; +import {optionalDepth} from '../../../helpers/dataTypes'; +import i18n from '../i18n'; +import './DataTypeTree.scss'; + +const block = cn('qp-data-type-tree'); + +function TypeChildren({type, level}: {type: ParameterType; level: number}) { + if ('struct' in type) { + return ( + <> + {type.struct.map((entry, index) => ( + + ))} + + ); + } + if (type.name === 'Enum') { + return ( + <> + {type.type.map((entry, index) => ( + + {entry.name} + + ))} + + ); + } + if ('type' in type) { + const children = Array.isArray(type.type) ? type.type : [type.type]; + return ( + <> + {children.map((child, index) => ( + + ))} + + ); + } + return null; +} + +export function DataTypeTree({ + type, + level = 0, + member, +}: { + type: ParameterType; + level?: number; + member?: string | number; +}) { + const [expanded, setExpanded] = useState(level < 2); + const complex = 'struct' in type || 'type' in type; + const depth = optionalDepth(type); + const name = type.name + (type.params?.length ? `(${type.params.join(', ')})` : ''); + return ( + + + {member !== undefined && {member}:} + {complex ? ( + + ) : ( + {name} + )} + {depth > 0 && ( + + {depth > 1 ? `Optional × ${depth}` : 'Optional'} + + )} + {type.tags?.map((tag, index) => ( + + [{tag}] + + ))} + + {complex && ( + + {expanded ? ( + + ) : ( + + )} + + )} + + ); +} diff --git a/src/components/MonacoEditor/MonacoEditor.tsx b/src/components/MonacoEditor/MonacoEditor.tsx index 23fdc53..bcf4153 100644 --- a/src/components/MonacoEditor/MonacoEditor.tsx +++ b/src/components/MonacoEditor/MonacoEditor.tsx @@ -12,6 +12,7 @@ type Props = { readOnly?: boolean; language?: string; theme?: string; + onUpdate?: (value: string) => void; onClick?: (e: monaco.editor.IEditorMouseEvent) => void; monacoConfig?: MonacoEditorConfig; className?: string; @@ -30,6 +31,7 @@ export const MonacoEditor: FC = ({ theme, readOnly, onClick, + onUpdate, monacoConfig, className, }) => { @@ -40,6 +42,9 @@ export const MonacoEditor: FC = ({ // on every render (avoids stale closures without extra effect churn). const onClickRef = useRef(onClick); onClickRef.current = onClick; + const onUpdateRef = useRef(onUpdate); + onUpdateRef.current = onUpdate; + const syncingValueRef = useRef(false); // Create the editor + model once per mount, dispose both on unmount. useEffect(() => { @@ -69,7 +74,12 @@ export const MonacoEditor: FC = ({ onClickRef.current?.(e); }); + const contentSubscription = editorInstance.onDidChangeModelContent(() => { + if (!syncingValueRef.current) onUpdateRef.current?.(model.getValue()); + }); + return () => { + contentSubscription.dispose(); mouseDownSubscription.dispose(); editorInstance.dispose(); model.dispose(); @@ -83,7 +93,12 @@ export const MonacoEditor: FC = ({ useEffect(() => { const model = modelRef.current; if (model && model.getValue() !== value) { - model.setValue(value); + syncingValueRef.current = true; + try { + model.setValue(value); + } finally { + syncingValueRef.current = false; + } } }, [value]); diff --git a/src/components/index.ts b/src/components/index.ts index 38ff13f..e3915b9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -71,3 +71,5 @@ export * from './AttachmentList'; export * from './AttachmentListPlaceholder'; export {QueryGraph} from './QueryGraph'; export type {QueryGraphProps} from './QueryGraph'; +export {DataTypePopup} from './DataTypePopup'; +export type {DataTypePopupProps} from './DataTypePopup'; diff --git a/src/helpers/dataTypes.ts b/src/helpers/dataTypes.ts new file mode 100644 index 0000000..4388e96 --- /dev/null +++ b/src/helpers/dataTypes.ts @@ -0,0 +1,15 @@ +import type {ParameterType} from '../types/parameterWizard'; + +export const optionalDepth = (type: ParameterType) => + type.optional ? Math.max(1, type.optionalLevel || 1) : 0; + +export function formatType(type: ParameterType): string { + let result: string = type.name; + if ('struct' in type) + result += `<${type.struct.map((field) => `${field.key}: ${formatType(field.type)}`).join(', ')}>`; + else if ('type' in type) { + result += `<${Array.isArray(type.type) ? type.type.map((item) => (type.name === 'Enum' ? item.name : formatType(item as ParameterType))).join(', ') : formatType(type.type)}>`; + } else if (type.params?.length) result += `(${type.params.join(', ')})`; + for (const tag of type.tags || []) result = `Tagged<${result}, ${tag}>`; + return result + '?'.repeat(optionalDepth(type)); +} diff --git a/src/index.ts b/src/index.ts index c0001b8..1ae8711 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,3 +11,6 @@ export * from './types/errorTree'; export * from './types/queryStatistics'; export * from './types/queryResults'; export * from './types/queryGraph'; + +export * from './types/parameterWizard'; +export * from './types/dataTypePopup'; diff --git a/src/modules/ParameterWizard/ParameterWizard.scss b/src/modules/ParameterWizard/ParameterWizard.scss new file mode 100644 index 0000000..4e5618a --- /dev/null +++ b/src/modules/ParameterWizard/ParameterWizard.scss @@ -0,0 +1,4 @@ +.qp-parameter-wizard { + min-width: 0; + width: 100%; +} diff --git a/src/modules/ParameterWizard/ParameterWizard.stories.tsx b/src/modules/ParameterWizard/ParameterWizard.stories.tsx new file mode 100644 index 0000000..bfdfab3 --- /dev/null +++ b/src/modules/ParameterWizard/ParameterWizard.stories.tsx @@ -0,0 +1,366 @@ +import React, {useEffect, useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Button, Flex, Text, ThemeProvider} from '@gravity-ui/uikit'; +import type { + ParameterPrimitiveTypeName, + ParameterType, + ParameterValue, + ParameterWizardProps, +} from '../../types/parameterWizard'; +import {ParameterWizard} from './ParameterWizard'; + +function ControlledWizard(props: ParameterWizardProps) { + const [value, setValue] = useState(props.value); + const [valid, setValid] = useState(false); + const [deleted, setDeleted] = useState(false); + useEffect(() => { + setValue(props.value); + setDeleted(false); + }, [props.value, props.type]); + if (deleted) return ; + return ( + + setValue(props.value)} + onDelete={props.onDelete ? () => setDeleted(true) : undefined} + /> + + + {valid ? 'Valid value' : 'Incomplete or invalid value'} + + + {JSON.stringify(value) ?? 'undefined'} + + + + ); +} + +const meta = { + title: 'Modules/ParameterWizard', + component: ParameterWizard, + tags: ['autodocs'], + parameters: {layout: 'padded'}, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: {name: '$limit', type: {name: 'Uint64'}, value: '100', onChange: () => {}}, + render: (args) => , +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Empty: Story = {args: {value: undefined}}; +export const InvalidValue: Story = {args: {type: {name: 'Int8'}, value: '256'}}; +export const ReadOnly: Story = {args: {readOnly: true}}; +export const Disabled: Story = {args: {disabled: true}}; +export const Deletable: Story = {args: {onDelete: () => {}}}; +export const Optional: Story = { + args: {name: '$nickname', type: {name: 'Utf8', optional: true}, value: null}, +}; +export const NestedOptional: Story = { + args: { + name: '$nested', + type: {name: 'Int64', optional: true, optionalLevel: 3}, + value: [[null]], + }, +}; +export const List: Story = { + args: { + name: '$ids', + type: {name: 'List', type: {name: 'Uint64'}}, + value: ['1', '18446744073709551615'], + }, +}; +export const EmptyList: Story = { + args: {name: '$items', type: {name: 'List', type: {name: 'Utf8'}}, value: []}, +}; +export const Tuple: Story = { + args: { + name: '$row', + type: { + name: 'Tuple', + type: [{name: 'Int32'}, {name: 'Utf8'}, {name: 'Bool', optional: true}], + }, + value: ['42', 'hello', [false]], + }, +}; +export const Dictionary: Story = { + args: { + name: '$scores', + type: {name: 'Dict', type: [{name: 'Utf8'}, {name: 'Int32'}]}, + value: {alice: '10', bob: '20'}, + }, +}; +export const Set: Story = { + args: { + name: '$unique', + type: {name: 'Set', type: {name: 'Utf8'}}, + value: [ + ['first', 'Void'], + ['second', 'Void'], + ], + }, +}; +export const Enum: Story = { + args: { + name: '$priority', + type: {name: 'Enum', type: [{name: 'low'}, {name: 'normal'}, {name: 'high'}]}, + value: 'normal', + }, +}; +export const Variant: Story = { + args: { + name: '$choice', + type: { + name: 'Variant', + struct: [ + {key: 'id', type: {name: 'Uint64'}}, + {key: 'name', type: {name: 'Utf8'}}, + ], + }, + value: [['id'], '42'], + }, +}; +export const TupleVariant: Story = { + args: { + name: '$choice', + type: { + name: 'Variant', + struct: [ + {key: 0, type: {name: 'Bool'}}, + {key: 1, type: {name: 'Utf8'}}, + ], + }, + value: ['0', false], + }, +}; + +const structType: ParameterType = { + name: 'Struct', + struct: [ + {key: 'name', type: {name: 'Utf8'}}, + {key: 'enabled', type: {name: 'Bool'}}, + {key: 'tags', type: {name: 'List', type: {name: 'Utf8'}}}, + { + key: 'address', + type: { + name: 'Struct', + optional: true, + struct: [ + {key: 'city', type: {name: 'Utf8'}}, + {key: 'zip', type: {name: 'Uint32'}}, + ], + }, + }, + ], +}; +const structValue: ParameterValue = { + name: 'Example', + enabled: true, + tags: ['production', 'daily'], + address: [{city: 'London', zip: '12345'}], +}; +export const NestedContainers: Story = { + args: {name: '$settings', type: structType, value: structValue}, +}; +export const ReadOnlyContainers: Story = {args: {...NestedContainers.args, readOnly: true}}; +export const InvalidContainer: Story = { + args: {name: '$ids', type: {name: 'List', type: {name: 'Int32'}}, value: {unexpected: 'value'}}, +}; +export const BinaryString: Story = { + args: {name: '$bytes', type: {name: 'String'}, value: ['/wAB']}, +}; +export const Uuid: Story = { + args: {name: '$uuid', type: {name: 'Uuid'}, value: ['AIQOVZvi1EGnFkRmVUQAAA==']}, +}; +export const Json: Story = { + args: { + name: '$document', + type: {name: 'Json'}, + value: {name: 'Example', active: true, scores: [1, 2, 3]}, + }, +}; +export const Yson: Story = { + args: { + name: '$yson', + type: {name: 'Yson'}, + value: {$value: '42', $type: 'int64', $attributes: {unit: {$value: 'ms', $type: 'string'}}}, + }, +}; +export const PostgreSQL: Story = {args: {name: '$pg', type: {name: 'pgtext'}, value: null}}; +export const UnknownType: Story = { + args: {name: '$custom', type: {name: 'Unknown'}, value: {custom: ['payload']}}, +}; +export const TaggedDecimal: Story = { + args: { + name: '$amount', + type: {name: 'Decimal', params: [22, 9], tagged: true, tags: ['Money']}, + value: '1234567890123.123456789', + }, +}; + +function DateGallery() { + const examples: {name: ParameterPrimitiveTypeName; value: string}[] = [ + {name: 'Date', value: '19509'}, + {name: 'Date32', value: '-8722'}, + {name: 'Datetime', value: '1686966302'}, + {name: 'Datetime64', value: '-753511371'}, + {name: 'Timestamp', value: '1685577600123456'}, + {name: 'Timestamp64', value: '-753511370765432'}, + {name: 'Interval', value: '1500000'}, + {name: 'Interval64', value: '9007199254740993'}, + {name: 'TzTimestamp', value: '2023-06-29T17:15:36.645735,Europe/Moscow'}, + ]; + return ( + + {examples.map((item) => ( + {}} + /> + ))} + + ); +} +export const DatesAndIntervals: Story = {render: () => }; + +function ExternalUpdates() { + const [value, setValue] = useState('10'); + return ( + + + + + ); +} +export const ControlledUpdates: Story = {render: () => }; +export const DarkTheme: Story = { + args: NestedContainers.args, + decorators: [ + (Story) => ( + + + + ), + ], +}; +export const Narrow: Story = { + args: NestedContainers.args, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +interface PrimitiveListExample { + name: ParameterPrimitiveTypeName; + values: ParameterValue[]; +} + +function PrimitiveLists({examples}: {examples: PrimitiveListExample[]}) { + return ( + + {examples.map(({name, values}) => ( + {}} + /> + ))} + + ); +} + +export const IntegerItems: Story = { + render: () => ( + + ), +}; + +export const StringItems: Story = { + render: () => ( + + ), +}; + +export const DateItems: Story = { + render: () => ( + + ), +}; diff --git a/src/modules/ParameterWizard/ParameterWizard.tsx b/src/modules/ParameterWizard/ParameterWizard.tsx new file mode 100644 index 0000000..d76a70f --- /dev/null +++ b/src/modules/ParameterWizard/ParameterWizard.tsx @@ -0,0 +1,98 @@ +import React, {useCallback, useEffect, useState} from 'react'; +import {ArrowRotateLeft, TrashBin} from '@gravity-ui/icons'; +import {ActionTooltip, Button, Flex, Icon} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import type {ParameterWizardProps} from '../../types/parameterWizard'; +import {validateValue} from './helpers/validateValue'; +import i18n from './i18n'; +import {ParameterEditor} from './internal/ParameterEditor'; +import {ValidationContext} from './internal/validation'; +import './ParameterWizard.scss'; + +const block = cn('qp-parameter-wizard'); + +export function ParameterWizard({ + name, + type, + value, + onChange, + onDelete, + onReset, + resetDisabled, + onValidationChange, + readOnly, + disabled, + className, +}: ParameterWizardProps) { + const [revision, setRevision] = useState(0); + const [invalidDrafts, setInvalidDrafts] = useState>(() => new Set()); + + const report = useCallback((id: string, invalid: boolean) => { + setInvalidDrafts((current) => { + if (current.has(id) === invalid) return current; + const next = new Set(current); + if (invalid) next.add(id); + else next.delete(id); + return next; + }); + }, []); + + const valid = invalidDrafts.size === 0 && validateValue(value, type); + + useEffect(() => { + onValidationChange?.(valid); + }, [valid, onValidationChange]); + + return ( + + + { + if (!readOnly && !disabled) onChange(next); + }} + readOnly={readOnly} + disabled={disabled} + actions={ + !readOnly && ( + + {onReset && ( + + + + )} + {onDelete && ( + + + + )} + + ) + } + /> + + + ); +} diff --git a/src/modules/ParameterWizard/README.md b/src/modules/ParameterWizard/README.md new file mode 100644 index 0000000..35949d5 --- /dev/null +++ b/src/modules/ParameterWizard/README.md @@ -0,0 +1,103 @@ +# ParameterWizard + +Controlled editor for one typed query parameter. It has no dependency on the YQL +application, query context, network APIs, persistence, or query execution events. +It belongs to the **modules** level because it composes a recursive editing +scenario; its editors are internal implementation details. + +```tsx +import React, {useState} from 'react'; +import {ParameterWizard} from '@gravity-ui/querieskit'; +import type {ParameterValue} from '@gravity-ui/querieskit'; + +const savedValue = ['42']; + +export function Parameter() { + const [value, setValue] = useState(savedValue); + const [valid, setValid] = useState(false); + + return ( + setValue(savedValue)} + onValidationChange={setValid} + /> + ); +} +``` + +The host application uses `valid` to gate its own submit/save action. No callbacks +are fired to initialize or normalize values on mount. `onValidationChange` reports +validity on mount and when it changes. + +## API + +- `name`, `type`, `value`, `onChange`: controlled parameter identity, parsed type, + restricted JSON value and change callback. `undefined` denotes an unfilled value. +- `onReset`: optional action; the caller restores its saved value. Local input + drafts are discarded even when the saved value equals the current value. +- `onDelete`: optional action; the caller removes the parameter. +- `resetDisabled`, `disabled`, `readOnly`, `className`: action state and integration. + Read-only mode still allows inspecting containers and switching display modes. +- `onValidationChange`: includes incomplete nested fields and invalid local drafts. + Invalid text stays visible with an error and does not overwrite the controlled + value. Accept emitted values synchronously in `onChange`; persistence can run + separately. Changing `value` externally refreshes the editor. Use a React `key` + when replacing an entire editing session with the same value. + +## Type and value contracts + +`ParameterType`, `ParameterTypeMember`, `ParameterPrimitiveTypeName`, +`ParameterValue` and `ParameterWizardProps` are public exports from `src/types`. +The type descriptor follows the original wizard's parsed type shape, without +imports from YQL models. Parsing SQL declarations or backend type arrays remains +in the host application. + +Values follow the [restricted JSON format](https://yql.yandex-team.ru/docs/types/json): + +| Type | Value | +| ------------------------------------------- | ----------------------------------------------------------------------------- | +| Int, Uint, Float, Double, Decimal, DyNumber | Strings, including 64-bit integers | +| Bool | Boolean | +| Utf8 | String | +| String and PostgreSQL | Text or a one-element base64 array; PostgreSQL also permits null | +| Uuid | One-element base64 array, edited as a canonical UUID | +| Date / Datetime / Timestamp | String containing days / seconds / microseconds since the epoch | +| Interval | String containing microseconds, edited as an ISO duration | +| Json, JsonDocument, Yson | JSON value; Yson uses its JSON representation with typed attributes | +| List, Tuple | Arrays | +| Struct | Object or positional array; editing preserves this choice | +| Dict | Array of key/value pairs; string-key objects are accepted and edited as pairs | +| Set | Dict with Void values: `[[key, "Void"]]` | +| Enum | String | +| Variant | `[[fieldName], value]` or `["index", value]` | +| Optional | `null` / `[]` or `[value]`; nested levels are selected independently | +| Void, Null, EmptyList, EmptyDict | Their singleton values | + +Dates are edited in UTC; conversion uses bigint to preserve microseconds, including +negative timestamps and the extended date types. Tagged types retain their metadata +and use the underlying editor. Container rows can be expanded, added and removed. +Unknown types and incompatible incoming container shapes use the JSON editor. +JSON mode can also be enabled explicitly for container types. It uses Monaco Editor +with JSON syntax highlighting, line numbers, folding, and the current UI theme. +Invalid JSON remains a local draft until it matches the parameter type. + +Validation covers the value representation, integer/date ranges, UUID/base64 and +Decimal precision/scale when supplied. PostgreSQL values, timezone identifiers, +Yson attributes and other backend-specific semantics remain the backend's +responsibility; this component does not replace server validation. + +## Migration from the YQL container + +Parse stored JSON before passing `value`, and serialize it in the host's persistence +layer. Replace `useParameterViewModel` with `value`/`onChange`, saved-state handling +with `onReset`, and `canDelete`/context deletion with `onDelete`. The widget emits +edits directly and does not listen to `SaveQueryAction`, `ValidateQueryAction` or +`ExecuteQueryAction`. Existing YQL source files are not modified. + +See **Modules / ParameterWizard** in Storybook for interactive examples, including +nested containers, Optional levels, exact 64-bit values, binary/UUID, JSON/Yson, +invalid values, external updates, deletion, read-only/disabled mode and dark theme. diff --git a/src/modules/ParameterWizard/helpers/binary.ts b/src/modules/ParameterWizard/helpers/binary.ts new file mode 100644 index 0000000..913c9c8 --- /dev/null +++ b/src/modules/ParameterWizard/helpers/binary.ts @@ -0,0 +1,49 @@ +export function isValidHexString(hexStr: string) { + if (hexStr === '') return true; + return /^(?:[0-9a-fA-F]{2})+$/.test(hexStr); +} + +export function hexToBase64(hexString: string) { + if (hexString === '') return ''; + const bytesInHexFormat = hexString.match(/.{1,2}/g) as string[]; + const bytes = new Uint8Array(bytesInHexFormat.map((hexByte) => parseInt(hexByte, 16))); + const binaryString = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(''); + return btoa(binaryString); +} + +export function base64ToHex(base64String: string) { + const binaryString = atob(base64String); + let hex = ''; + for (let i = 0; i < binaryString.length; i++) { + const byte = binaryString.charCodeAt(i).toString(16); + hex += byte.padStart(2, '0'); + } + return hex; +} + +export function reverseUuidBytes(hexUuid: string) { + const reverseBytes = (bytes: string, start: number, length: number) => { + let res = ''; + for (let i = 0; i < length; i++) { + res += bytes.slice(start + (length - 1 - i) * 2, start + (length - 1 - i) * 2 + 2); + } + return res; + }; + + const leHex = + reverseBytes(hexUuid, 0, 4) + + reverseBytes(hexUuid, 8, 2) + + reverseBytes(hexUuid, 12, 2) + + hexUuid.slice(16, 32); + + return leHex; +} + +export function isValidBase64String(str: string) { + try { + atob(str); + return true; + } catch { + return false; + } +} diff --git a/src/modules/ParameterWizard/helpers/date.ts b/src/modules/ParameterWizard/helpers/date.ts new file mode 100644 index 0000000..a8362fa --- /dev/null +++ b/src/modules/ParameterWizard/helpers/date.ts @@ -0,0 +1,106 @@ +const SECOND = 1_000_000n; +const DAY = 86400n * SECOND; + +const ranges: Record = { + Date: [0n, 49672n], + Date32: [-53375809n, 53375807n], + Datetime: [0n, 4291747199n], + Datetime64: [-4611669897600n, 4611638361599n], + Timestamp: [0n, 4291747199999999n], + Timestamp64: [-4611669897600000000n, 4611638361599999999n], + Interval: [-4291747199999999n, 4291747199999999n], + Interval64: [-9223339190399999999n, 9223339190399999999n], +}; + +export const isDateType = (name: string) => Object.prototype.hasOwnProperty.call(ranges, name); + +export function isDateValue(value: string, name: string) { + if (!/^-?\d+$/.test(value)) return false; + const range = ranges[name]; + return Boolean(range && BigInt(value) >= range[0] && BigInt(value) <= range[1]); +} + +const pad = (value: number, length = 2) => String(value).padStart(length, '0'); + +/** UTC conversion uses bigint for microseconds, including dates before the epoch. */ +export function formatDate(value: string, name: string): string { + if (!isDateValue(value, name)) return value; + const integer = BigInt(value); + if (name.startsWith('Interval')) { + const absolute = integer < 0n ? -integer : integer; + const seconds = absolute / SECOND; + const fraction = String(absolute % SECOND).padStart(6, '0'); + return `${integer < 0n ? '-' : ''}PT${seconds}.${fraction}S`; + } + const micros = name.startsWith('Timestamp') + ? integer + : integer * (name === 'Date' || name === 'Date32' ? DAY : SECOND); + const seconds = micros >= 0n ? micros / SECOND : (micros - SECOND + 1n) / SECOND; + const date = new Date(Number(seconds) * 1000); + // YQL has no year zero; JS uses astronomical year numbering. + const year = date.getUTCFullYear(); + const yqlYear = year <= 0 ? year - 1 : year; + const datePart = `${yqlYear < 0 ? '-' : ''}${pad(Math.abs(yqlYear), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`; + if (name === 'Date' || name === 'Date32') return datePart; + const time = `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; + const fraction = name.startsWith('Timestamp') + ? `.${String(micros - seconds * SECOND).padStart(6, '0')}` + : ''; + return `${datePart}T${time}${fraction}Z`; +} + +function parseInterval(text: string, name: string): string | undefined { + const match = + /^(-)?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.(\d{1,6}))?S)?)?$/.exec( + text, + ); + if (!match || !match.slice(2).some(Boolean) || text.endsWith('T')) return undefined; + const [, negative, weeks, days, hours, minutes, seconds, fraction] = match; + const micros = + (BigInt(weeks || 0) * 7n * DAY + + BigInt(days || 0) * DAY + + BigInt(hours || 0) * 3600n * SECOND + + BigInt(minutes || 0) * 60n * SECOND + + BigInt(seconds || 0) * SECOND + + BigInt((fraction || '').padEnd(6, '0'))) * + (negative ? -1n : 1n); + return isDateValue(String(micros), name) ? String(micros) : undefined; +} + +export function parseDate(text: string, name: string): string | undefined { + if (name.startsWith('Interval')) return parseInterval(text, name); + const dateOnly = name === 'Date' || name === 'Date32'; + const match = + /^(-?\d{4,6})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?Z)?$/.exec(text); + if (!match || (dateOnly ? match[4] !== undefined : match[4] === undefined)) return undefined; + const [, yearText, month, day, hour = '0', minute = '0', second = '0', fraction = ''] = match; + const year = Number(yearText); + if (year === 0 || (!name.startsWith('Timestamp') && fraction)) return undefined; + const jsYear = year < 0 ? year + 1 : year; + const date = new Date(0); + date.setUTCFullYear(jsYear, Number(month) - 1, Number(day)); + date.setUTCHours(Number(hour), Number(minute), Number(second), 0); + const actual = [ + date.getUTCFullYear(), + date.getUTCMonth() + 1, + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + ]; + const expected = [ + jsYear, + Number(month), + Number(day), + Number(hour), + Number(minute), + Number(second), + ]; + if (!actual.every((part, index) => part === expected[index])) return undefined; + const seconds = BigInt(date.getTime() / 1000); + let result = seconds; + if (dateOnly) result = seconds / 86400n; + else if (name.startsWith('Timestamp')) + result = seconds * SECOND + BigInt(fraction.padEnd(6, '0')); + return isDateValue(String(result), name) ? String(result) : undefined; +} diff --git a/src/modules/ParameterWizard/helpers/validateValue.ts b/src/modules/ParameterWizard/helpers/validateValue.ts new file mode 100644 index 0000000..6cf9f8f --- /dev/null +++ b/src/modules/ParameterWizard/helpers/validateValue.ts @@ -0,0 +1,149 @@ +import type {ParameterType, ParameterValue} from '../../../types/parameterWizard'; +import {isDateType, isDateValue} from './date'; +import {isBinary, isPgType, isRecord, optionalDepth, variantIndex} from './values'; + +const decimalPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i; +const specialNumberPattern = /^[+-]?(?:inf|nan)$/i; + +function validateInteger(value: ParameterValue, name: string, bits: bigint) { + if (typeof value !== 'string' || !/^-?\d+$/.test(value)) return false; + const unsigned = name.startsWith('Uint'); + const min = unsigned ? 0n : -(2n ** (bits - 1n)); + const max = unsigned ? 2n ** bits - 1n : 2n ** (bits - 1n) - 1n; + return BigInt(value) >= min && BigInt(value) <= max; +} + +function validateDecimal(value: ParameterValue, type: ParameterType) { + if (typeof value !== 'string') return false; + if (specialNumberPattern.test(value)) return true; + const match = /^[+-]?(\d+)(?:\.(\d*))?$/.exec(value); + if (!match) return false; + const [precision, scale] = type.params?.map(Number) || []; + if (!Number.isInteger(precision) || !Number.isInteger(scale)) return true; + return ( + match[1].replace(/^0+/, '').length <= precision - scale && (match[2]?.length || 0) <= scale + ); +} + +const primitiveValidators: Record boolean> = { + Bool: (value) => typeof value === 'boolean', + String: (value) => typeof value === 'string' || isBinary(value), + Uuid: (value) => isBinary(value) && atob(value[0]).length === 16, + Utf8: (value) => typeof value === 'string', + Float: (value) => + typeof value === 'string' && + (specialNumberPattern.test(value) || decimalPattern.test(value)), + Double: (value) => + typeof value === 'string' && + (specialNumberPattern.test(value) || decimalPattern.test(value)), + DyNumber: (value) => typeof value === 'string' && decimalPattern.test(value), + Void: (value) => value === 'Void', + Null: (value) => value === null, + EmptyList: (value) => Array.isArray(value) && value.length === 0, + EmptyDict: (value) => + (Array.isArray(value) && value.length === 0) || + (isRecord(value) && Object.keys(value).length === 0), +}; + +function validateStruct( + value: ParameterValue, + type: Extract, +) { + if (Array.isArray(value)) + return ( + value.length === type.struct.length && + type.struct.every((field, index) => validateValue(value[index], field.type)) + ); + if (!isRecord(value)) return false; + if (Object.keys(value).some((key) => !type.struct.some((field) => String(field.key) === key))) + return false; + return type.struct.every((field) => { + if (Object.prototype.hasOwnProperty.call(value, field.key)) + return validateValue(value[field.key], field.type); + return Boolean(field.type.optional); + }); +} + +function validateDict(value: ParameterValue, type: Extract) { + const entries = + isRecord(value) && !type.type[0].optional && ['String', 'Utf8'].includes(type.type[0].name) + ? Object.entries(value) + : value; + return ( + Array.isArray(entries) && + entries.every( + (entry) => + Array.isArray(entry) && + entry.length === 2 && + validateValue(entry[0], type.type[0]) && + validateValue(entry[1], type.type[1]), + ) + ); +} + +function validateContainer(value: ParameterValue, type: ParameterType): boolean { + switch (type.name) { + case 'Struct': + return validateStruct(value, type); + case 'Dict': + return validateDict(value, type); + case 'Enum': + return ( + typeof value === 'string' && type.type.some((entry) => String(entry.name) === value) + ); + case 'List': + case 'Stream': + return Array.isArray(value) && value.every((item) => validateValue(item, type.type)); + case 'Set': + return ( + Array.isArray(value) && + value.every( + (item) => + Array.isArray(item) && + item.length === 2 && + validateValue(item[0], type.type) && + item[1] === 'Void', + ) + ); + case 'Tuple': + return ( + Array.isArray(value) && + value.length === type.type.length && + type.type.every((child, index) => validateValue(value[index], child)) + ); + case 'Variant': { + const index = variantIndex(value, type); + return ( + index >= 0 && + Array.isArray(value) && + validateValue(value[1], type.struct[index].type) + ); + } + default: + return true; + } +} + +/** Checks the restricted JSON representation without coercing numbers through JS Number. */ +export function validateValue(value: ParameterValue, type: ParameterType): boolean { + if (value === undefined) return false; + const depth = optionalDepth(type); + if (depth) { + if (value === null || (Array.isArray(value) && value.length === 0)) return true; + return ( + Array.isArray(value) && + value.length === 1 && + validateValue(value[0], {...type, optional: depth > 1, optionalLevel: depth - 1}) + ); + } + const name = type.name; + if (isPgType(name)) return value === null || typeof value === 'string' || isBinary(value); + if (isDateType(name)) return typeof value === 'string' && isDateValue(value, name); + const int = /^(?:Uint|Int)(8|16|32|64)$/.exec(name); + if (int) return validateInteger(value, name, BigInt(int[1])); + if (name === 'Decimal') return validateDecimal(value, type); + if (name.startsWith('Tz')) return typeof value === 'string'; + if (Object.prototype.hasOwnProperty.call(primitiveValidators, name)) + return primitiveValidators[name](value); + return validateContainer(value, type); +} diff --git a/src/modules/ParameterWizard/helpers/values.ts b/src/modules/ParameterWizard/helpers/values.ts new file mode 100644 index 0000000..2ef4d9e --- /dev/null +++ b/src/modules/ParameterWizard/helpers/values.ts @@ -0,0 +1,86 @@ +import type {ParameterType, ParameterValue} from '../../../types/parameterWizard'; +import {optionalDepth} from '../../../helpers/dataTypes'; +export {optionalDepth} from '../../../helpers/dataTypes'; +import {isValidBase64String} from './binary'; + +export const isPgType = (name: string) => /^_?pg/.test(name); +export const isRecord = (value: ParameterValue): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +export function wrapOptional(value: ParameterValue, depth: number): ParameterValue { + let result = value; + for (let i = 0; i < depth; i++) result = [result]; + return result; +} + +export function readOptional(value: ParameterValue, depth: number) { + let current = value; + for (let level = 0; level < depth; level++) { + if (current === null || (Array.isArray(current) && current.length === 0)) { + return {value: undefined, nullLevel: level, validShape: true}; + } + if (!Array.isArray(current) || current.length !== 1) { + return {value: current, nullLevel: undefined, validShape: current === undefined}; + } + current = current[0]; + } + return {value: current, nullLevel: undefined, validShape: true}; +} + +export function defaultValue(type: ParameterType): ParameterValue { + if (optionalDepth(type) || isPgType(type.name) || type.name === 'Null') return null; + switch (type.name) { + case 'Void': + return 'Void'; + case 'List': + case 'Set': + case 'Dict': + case 'EmptyList': + case 'EmptyDict': + return []; + case 'Tuple': + return type.type.map(defaultValue); + case 'Struct': + return Object.fromEntries( + type.struct.map((field) => [field.key, defaultValue(field.type)]), + ); + case 'Enum': + return type.type[0] ? String(type.type[0].name) : undefined; + default: + return undefined; + } +} + +export function variantIndex( + value: ParameterValue, + type: Extract, +) { + if (!Array.isArray(value) || value.length !== 2) return -1; + const selector = value[0]; + if (Array.isArray(selector) && selector.length === 1) { + return type.struct.findIndex( + (field) => typeof field.key === 'string' && field.key === selector[0], + ); + } + if (typeof selector !== 'string' || !/^\d+$/.test(selector)) return -1; + const index = Number(selector); + return index < type.struct.length ? index : -1; +} + +export function variantValue( + type: Extract, + index: number, + value: ParameterValue, +): ParameterValue { + const key = type.struct[index].key; + return [typeof key === 'number' ? String(index) : [key], value]; +} + +export function isBinary(value: ParameterValue): value is [string] { + return ( + Array.isArray(value) && + value.length === 1 && + typeof value[0] === 'string' && + isValidBase64String(value[0]) + ); +} diff --git a/src/modules/ParameterWizard/i18n/dicts.ts b/src/modules/ParameterWizard/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/modules/ParameterWizard/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/modules/ParameterWizard/i18n/en.json b/src/modules/ParameterWizard/i18n/en.json new file mode 100644 index 0000000..49d7e6c --- /dev/null +++ b/src/modules/ParameterWizard/i18n/en.json @@ -0,0 +1,25 @@ +{ + "action_add-item": "Add item", + "action_delete": "Delete parameter", + "action_delete-item": "Delete item", + "action_reset": "Reset value", + "action_expand": "Expand", + "action_collapse": "Collapse", + "action_raw": "JSON", + "action_form": "Form", + "action_binary": "Hex", + "action_text": "Text", + "field_variant": "Variant", + "field_optional": "Optional value", + "field_key": "Key", + "field_value": "Value", + "value_present": "Value", + "value_null": "NULL", + "value_null-level": "NULL at level {{level}}", + "context_required": "Required value", + "context_empty": "No items", + "context_json": "Enter a JSON value", + "alert_invalid-value": "The value does not match the type", + "alert_invalid-json": "Enter valid JSON matching the type", + "action_set-value": "Set value" +} diff --git a/src/modules/ParameterWizard/i18n/index.ts b/src/modules/ParameterWizard/i18n/index.ts new file mode 100644 index 0000000..6147185 --- /dev/null +++ b/src/modules/ParameterWizard/i18n/index.ts @@ -0,0 +1,5 @@ +import {addI18Keysets} from '../../../i18n'; +import dicts from './dicts'; + +// Labels and validation messages shared by the parameter editors. +export default addI18Keysets('qp:parameter-wizard', dicts); diff --git a/src/modules/ParameterWizard/i18n/ru.json b/src/modules/ParameterWizard/i18n/ru.json new file mode 100644 index 0000000..c341ce9 --- /dev/null +++ b/src/modules/ParameterWizard/i18n/ru.json @@ -0,0 +1,25 @@ +{ + "action_add-item": "Добавить элемент", + "action_delete": "Удалить параметр", + "action_delete-item": "Удалить элемент", + "action_reset": "Сбросить значение", + "action_expand": "Развернуть", + "action_collapse": "Свернуть", + "action_raw": "JSON", + "action_form": "Форма", + "action_binary": "Hex", + "action_text": "Текст", + "field_variant": "Вариант", + "field_optional": "Опциональное значение", + "field_key": "Ключ", + "field_value": "Значение", + "value_present": "Значение", + "value_null": "NULL", + "value_null-level": "NULL на уровне {{level}}", + "context_required": "Обязательное значение", + "context_empty": "Нет элементов", + "context_json": "Введите значение в формате JSON", + "alert_invalid-value": "Значение не соответствует типу", + "alert_invalid-json": "Введите корректный JSON, соответствующий типу", + "action_set-value": "Задать значение" +} diff --git a/src/modules/ParameterWizard/index.ts b/src/modules/ParameterWizard/index.ts new file mode 100644 index 0000000..c88799f --- /dev/null +++ b/src/modules/ParameterWizard/index.ts @@ -0,0 +1,8 @@ +export {ParameterWizard} from './ParameterWizard'; +export type { + ParameterWizardProps, + ParameterType, + ParameterTypeMember, + ParameterPrimitiveTypeName, + ParameterValue, +} from '../../types/parameterWizard'; diff --git a/src/modules/ParameterWizard/internal/ContainerEditor.scss b/src/modules/ParameterWizard/internal/ContainerEditor.scss new file mode 100644 index 0000000..d8d7487 --- /dev/null +++ b/src/modules/ParameterWizard/internal/ContainerEditor.scss @@ -0,0 +1,3 @@ +.qp-parameter-container { + min-width: 0; +} diff --git a/src/modules/ParameterWizard/internal/ContainerEditor.tsx b/src/modules/ParameterWizard/internal/ContainerEditor.tsx new file mode 100644 index 0000000..08cba76 --- /dev/null +++ b/src/modules/ParameterWizard/internal/ContainerEditor.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import {TrashBin} from '@gravity-ui/icons'; +import {ActionTooltip, Button, Flex, Icon, Select, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import type {ParameterType, ParameterValue} from '../../../types/parameterWizard'; +import {defaultValue, isRecord, variantIndex, variantValue} from '../helpers/values'; +import i18n from '../i18n'; +import type {EditorProps} from './types'; +import {useItemKeys} from './useItemKeys'; +import './ContainerEditor.scss'; + +const block = cn('qp-parameter-container'); + +function getStructValue( + value: ParameterValue, + key: string | number, + index: number, + optional?: boolean, +) { + if (Array.isArray(value)) return value[index]; + if (isRecord(value)) { + if (Object.prototype.hasOwnProperty.call(value, key)) return value[key]; + if (optional) return null; + } + return undefined; +} + +interface ContainerEditorProps extends EditorProps { + renderEditor: (props: EditorProps) => React.ReactNode; +} + +export function ContainerEditor({ + type, + value, + onChange, + name, + disabled, + readOnly, + renderEditor, +}: ContainerEditorProps) { + let items: ParameterValue[] = []; + if (Array.isArray(value)) items = value; + else if (isRecord(value) && type.name === 'Dict') items = Object.entries(value); + const {keys, removeKey} = useItemKeys(items.length); + const renderChild = ( + childType: ParameterType, + childValue: ParameterValue, + childName: string, + change: (value: ParameterValue) => void, + itemAction?: React.ReactNode, + ) => + renderEditor({ + type: childType, + value: childValue, + name: childName, + hideLabel: true, + onChange: change, + itemAction, + disabled, + readOnly, + }); + + if (type.name === 'Variant') { + const index = variantIndex(value, type); + const field = type.struct[index]; + return ( + + + ); +} diff --git a/src/modules/ParameterWizard/internal/OptionalValueControl.tsx b/src/modules/ParameterWizard/internal/OptionalValueControl.tsx new file mode 100644 index 0000000..d127c4e --- /dev/null +++ b/src/modules/ParameterWizard/internal/OptionalValueControl.tsx @@ -0,0 +1,54 @@ +import React, {useRef} from 'react'; +import {Select} from '@gravity-ui/uikit'; +import type {ParameterValue} from '../../../types/parameterWizard'; +import {defaultValue, isPgType, optionalDepth, readOptional, wrapOptional} from '../helpers/values'; +import i18n from '../i18n'; +import type {EditorProps} from './types'; + +export function OptionalValueControl({ + name, + type, + value, + onChange, + disabled, + readOnly, +}: EditorProps) { + const previous = useRef<{value: ParameterValue} | undefined>(undefined); + const depth = optionalDepth(type); + const optional = readOptional(value, depth); + const pg = isPgType(type.name); + const pgNull = pg && optional.value === null; + let selectedValue = pgNull ? 'pg-null' : 'value'; + if (optional.nullLevel !== undefined) selectedValue = String(optional.nullLevel); + const options = [ + {value: 'value', content: i18n('value_present')}, + ...Array.from({length: depth}, (_, level) => ({ + value: String(level), + content: + depth === 1 ? i18n('value_null') : i18n('value_null-level', {level: level + 1}), + })), + ]; + if (pg) options.push({value: 'pg-null', content: i18n('value_null')}); + return ( + + onChange(type.name === 'Bool' ? selected === 'true' : selected) + } + /> + ); + } + if (['Void', 'Null', 'EmptyList', 'EmptyDict'].includes(type.name)) { + if (value === undefined && !readOnly) + return ( + + ); + return {JSON.stringify(value) ?? i18n('context_required')}; + } + if (['Unknown', 'Json', 'JsonDocument', 'Yson', 'Stream'].includes(type.name)) + return ; + if (isDateType(type.name)) { + return ( + (v === undefined ? '' : formatDate(String(v), type.name))} + parse={(text) => { + const parsed = parseDate(text, type.name); + return {valid: text === '' || parsed !== undefined, value: parsed}; + }} + /> + ); + } + const uuid = type.name === 'Uuid'; + const canBeBinary = type.name === 'String' || isPgType(type.name); + return ( + + {canBeBinary && ( + + )} + { + if (v === undefined) return ''; + if (uuid) { + if (!isBinary(v)) return JSON.stringify(v); + const hex = reverseUuidBytes(base64ToHex(v[0])); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + if (canBeBinary && binary) { + if (isBinary(v)) return base64ToHex(v[0]); + return Array.from(new TextEncoder().encode(String(v)), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + } + if (canBeBinary && isBinary(v)) return decoded ?? ''; + return String(v); + }} + parse={(text) => { + if (uuid) { + const hex = text.replace(/-/g, ''); + const valid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + text, + ); + return { + valid: text === '' || valid, + value: valid ? [hexToBase64(reverseUuidBytes(hex))] : undefined, + }; + } + if (canBeBinary && binary) + return { + valid: isValidHexString(text), + value: isValidHexString(text) ? [hexToBase64(text)] : undefined, + }; + const nextValue = + text === '' && type.name !== 'Utf8' && !canBeBinary ? undefined : text; + return { + valid: nextValue === undefined || validateValue(nextValue, type), + value: nextValue, + }; + }} + /> + + ); +} diff --git a/src/modules/ParameterWizard/internal/RawEditor.scss b/src/modules/ParameterWizard/internal/RawEditor.scss new file mode 100644 index 0000000..74d7d0f --- /dev/null +++ b/src/modules/ParameterWizard/internal/RawEditor.scss @@ -0,0 +1,19 @@ +.qp-parameter-raw-editor { + min-width: 0; + + &_disabled { + opacity: 0.5; + } + + &__editor { + height: 240px; + min-width: 0; + overflow: hidden; + border: 1px solid var(--g-color-line-generic); + border-radius: var(--g-border-radius-m); + + &_invalid { + border-color: var(--g-color-line-danger); + } + } +} diff --git a/src/modules/ParameterWizard/internal/RawEditor.tsx b/src/modules/ParameterWizard/internal/RawEditor.tsx new file mode 100644 index 0000000..3842ff5 --- /dev/null +++ b/src/modules/ParameterWizard/internal/RawEditor.tsx @@ -0,0 +1,61 @@ +import React, {useMemo} from 'react'; +import {Flex, Text, useThemeValue} from '@gravity-ui/uikit'; +import 'monaco-editor/language/json/monaco.contribution'; +import cn from 'bem-cn-lite'; +import {MonacoEditor} from '../../../components/MonacoEditor'; +import type {MonacoEditorConfig} from '../../../components/MonacoEditor'; +import {validateValue} from '../helpers/validateValue'; +import i18n from '../i18n'; +import {useDraftValue} from './useDraftValue'; +import type {EditorProps} from './types'; +import './RawEditor.scss'; + +const block = cn('qp-parameter-raw-editor'); + +export function RawEditor({value, onChange, type, name, readOnly, disabled}: EditorProps) { + const theme = useThemeValue(); + const {text, invalid, onUpdate} = useDraftValue({ + value, + onChange, + format: (current) => JSON.stringify(current, null, 2) ?? '', + parse: (input) => { + if (!input.trim()) return {valid: true, value: undefined}; + try { + const next = JSON.parse(input); + return {valid: validateValue(next, type), value: next}; + } catch { + return {valid: false, value: undefined}; + } + }, + }); + const config = useMemo( + () => ({ + ariaLabel: name, + tabIndex: disabled ? -1 : 0, + scrollBeyondLastLine: false, + wordWrap: 'on', + lineNumbersMinChars: 3, + folding: true, + tabSize: 2, + padding: {top: 8, bottom: 8}, + }), + [name, disabled], + ); + const showError = invalid && text !== '' && !disabled; + return ( + + { + if (!readOnly && !disabled) onUpdate(next); + }} + /> + {showError && {i18n('alert_invalid-json')}} + + ); +} diff --git a/src/modules/ParameterWizard/internal/types.ts b/src/modules/ParameterWizard/internal/types.ts new file mode 100644 index 0000000..59b9159 --- /dev/null +++ b/src/modules/ParameterWizard/internal/types.ts @@ -0,0 +1,13 @@ +import type {ReactNode} from 'react'; +import type {ParameterType, ParameterValue} from '../../../types/parameterWizard'; + +export interface EditorProps { + type: ParameterType; + value: ParameterValue; + onChange: (value: ParameterValue) => void; + name: string; + readOnly?: boolean; + disabled?: boolean; + itemAction?: ReactNode; + hideLabel?: boolean; +} diff --git a/src/modules/ParameterWizard/internal/useDraftValue.ts b/src/modules/ParameterWizard/internal/useDraftValue.ts new file mode 100644 index 0000000..675c0ef --- /dev/null +++ b/src/modules/ParameterWizard/internal/useDraftValue.ts @@ -0,0 +1,32 @@ +import {useState} from 'react'; +import type {ParameterValue} from '../../../types/parameterWizard'; +import {useDraftValidation} from './validation'; + +interface DraftValueOptions { + value: ParameterValue; + onChange: (value: ParameterValue) => void; + format: (value: ParameterValue) => string; + parse: (text: string) => {valid: boolean; value: ParameterValue}; +} + +/** Invalid drafts stay local; external values replace them. */ +export function useDraftValue({value, onChange, format, parse}: DraftValueOptions) { + const [draft, setDraft] = useState<{source: ParameterValue; text: string} | undefined>(); + const matches = draft !== undefined && Object.is(draft.source, value); + + const text = matches ? draft.text : format(value); + + if (draft && !matches) setDraft(undefined); + + const parsed = parse(text); + const invalid = !parsed.valid; + + useDraftValidation(invalid); + + const handleUpdate = (nextText: string) => { + const next = parse(nextText); + setDraft({source: next.valid ? next.value : value, text: nextText}); + if (next.valid) onChange(next.value); + }; + return {text, invalid, onUpdate: handleUpdate}; +} diff --git a/src/modules/ParameterWizard/internal/useItemKeys.ts b/src/modules/ParameterWizard/internal/useItemKeys.ts new file mode 100644 index 0000000..f8aee10 --- /dev/null +++ b/src/modules/ParameterWizard/internal/useItemKeys.ts @@ -0,0 +1,23 @@ +import {useState} from 'react'; + +/** Keep sibling input drafts attached to their rows when an item is deleted. */ +export function useItemKeys(length: number) { + const [state, setState] = useState(() => ({ + keys: Array.from({length}, (_, index) => index), + next: length, + })); + let keys = state.keys; + if (keys.length !== length) { + const added = Math.max(0, length - keys.length); + keys = [...keys.slice(0, length), ...Array.from({length: added}, (_, i) => state.next + i)]; + setState({keys, next: state.next + added}); + } + return { + keys, + removeKey: (index: number) => + setState((current) => ({ + ...current, + keys: current.keys.filter((_, i) => i !== index), + })), + }; +} diff --git a/src/modules/ParameterWizard/internal/validation.ts b/src/modules/ParameterWizard/internal/validation.ts new file mode 100644 index 0000000..79310f6 --- /dev/null +++ b/src/modules/ParameterWizard/internal/validation.ts @@ -0,0 +1,12 @@ +import {createContext, useContext, useEffect, useId} from 'react'; + +export const ValidationContext = createContext<(id: string, invalid: boolean) => void>(() => {}); + +export function useDraftValidation(invalid: boolean) { + const report = useContext(ValidationContext); + const id = useId(); + useEffect(() => { + report(id, invalid); + return () => report(id, false); + }, [id, invalid, report]); +} diff --git a/src/modules/index.ts b/src/modules/index.ts index 10a483c..fadb399 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -50,3 +50,5 @@ export {NavigationMeta, buildMetaGroups} from './NavigationMeta'; export type {NavigationMetaProps, NavigationMetaViewConfig} from './NavigationMeta'; export {NavigationView, buildViewColumns} from './NavigationView'; export type {NavigationViewProps, NavigationViewViewConfig} from './NavigationView'; + +export {ParameterWizard} from './ParameterWizard'; diff --git a/src/types/dataTypePopup.ts b/src/types/dataTypePopup.ts new file mode 100644 index 0000000..6720e1c --- /dev/null +++ b/src/types/dataTypePopup.ts @@ -0,0 +1,9 @@ +import type {ReactNode} from 'react'; +import type {ParameterType} from './parameterWizard'; + +export interface DataTypePopupProps { + children: ReactNode; + type?: ParameterType; + hideIcon?: boolean; + className?: string; +} diff --git a/src/types/parameterWizard.ts b/src/types/parameterWizard.ts new file mode 100644 index 0000000..e6784b7 --- /dev/null +++ b/src/types/parameterWizard.ts @@ -0,0 +1,90 @@ +/** Parsed type descriptor. Optional and tagged wrappers are metadata on the underlying type. */ +export type ParameterType = { + optional?: boolean; + optionalLevel?: number; + tagged?: boolean; + tags?: string[]; + params?: unknown[]; +} & ( + | {name: ParameterPrimitiveTypeName; complex?: false} + | {name: 'List' | 'Set' | 'Stream'; complex?: true; type: ParameterType} + | {name: 'Tuple'; complex?: true; type: ParameterType[]} + | {name: 'Dict'; complex?: true; type: [ParameterType, ParameterType]} + | {name: 'Struct' | 'Variant'; complex?: true; struct: ParameterTypeMember[]} + | {name: 'Enum'; complex?: true; type: {name: string | number}[]} +); + +export type ParameterPrimitiveTypeName = + | 'Bool' + | 'Int8' + | 'Int16' + | 'Int32' + | 'Int64' + | 'Uint8' + | 'Uint16' + | 'Uint32' + | 'Uint64' + | 'Float' + | 'Double' + | 'Decimal' + | 'DyNumber' + | 'String' + | 'Utf8' + | 'Uuid' + | 'Json' + | 'JsonDocument' + | 'Yson' + | 'Date' + | 'Date32' + | 'Datetime' + | 'Datetime64' + | 'Timestamp' + | 'Timestamp64' + | 'Interval' + | 'Interval64' + | 'TzDate' + | 'TzDate32' + | 'TzDatetime' + | 'TzDatetime64' + | 'TzTimestamp' + | 'TzTimestamp64' + | 'Void' + | 'Null' + | 'EmptyList' + | 'EmptyDict' + | 'Unknown' + | `pg${string}` + | `_pg${string}`; + +export interface ParameterTypeMember { + /** Strings identify struct fields; numbers identify tuple variants. */ + key: string | number; + type: ParameterType; +} + +/** Restricted JSON value, not JSON source text. Undefined denotes an unfilled field. */ +export type ParameterValue = + | undefined + | null + | boolean + | string + | number + | ParameterValue[] + | {[key: string]: ParameterValue}; + +export interface ParameterWizardProps { + name: string; + type: ParameterType; + value: ParameterValue; + onChange: (value: ParameterValue) => void; + readOnly?: boolean; + disabled?: boolean; + className?: string; + /** Presence of this callback enables the delete action. */ + onDelete?: () => void; + /** The consumer owns the saved value and restores it in this callback. */ + onReset?: () => void; + resetDisabled?: boolean; + /** Includes incomplete values and invalid local input drafts. */ + onValidationChange?: (valid: boolean) => void; +} From 3e56aa5512ccb4e00d28c4af090cd1f91f5a5f70 Mon Sep 17 00:00:00 2001 From: Victor Trumpel Date: Wed, 14 Oct 2026 18:03:10 +0300 Subject: [PATCH 2/3] fix: fix monaco editor --- src/components/MonacoEditor/MonacoEditor.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/MonacoEditor/MonacoEditor.tsx b/src/components/MonacoEditor/MonacoEditor.tsx index 1e94874..cfb949a 100644 --- a/src/components/MonacoEditor/MonacoEditor.tsx +++ b/src/components/MonacoEditor/MonacoEditor.tsx @@ -162,10 +162,6 @@ export const MonacoEditor: FC = ({ if (!syncingValueRef.current) onChangeRef.current?.(model.getValue()); }); - const contentSubscription = editorInstance.onDidChangeModelContent(() => { - if (!syncingValueRef.current) onUpdateRef.current?.(model.getValue()); - }); - return () => { for (const release of [...ownedCleanups]) { try { From a68d3e029be6d89a98d489f0d306e32415acd480 Mon Sep 17 00:00:00 2001 From: Victor Trumpel Date: Thu, 15 Oct 2026 11:04:03 +0300 Subject: [PATCH 3/3] fix: use MonacoEditor onChange in parameter wizard --- src/modules/ParameterWizard/internal/RawEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/ParameterWizard/internal/RawEditor.tsx b/src/modules/ParameterWizard/internal/RawEditor.tsx index 3842ff5..babea77 100644 --- a/src/modules/ParameterWizard/internal/RawEditor.tsx +++ b/src/modules/ParameterWizard/internal/RawEditor.tsx @@ -51,7 +51,7 @@ export function RawEditor({value, onChange, type, name, readOnly, disabled}: Edi theme={theme} readOnly={readOnly || disabled} monacoConfig={config} - onUpdate={(next) => { + onChange={(next) => { if (!readOnly && !disabled) onUpdate(next); }} />