diff --git a/package.json b/package.json index 527112a..3e4d146 100644 --- a/package.json +++ b/package.json @@ -266,6 +266,16 @@ "default": "./build/cjs/modules/QueryComparison/index.js" } }, + "./components/DataTypePopup": { + "import": { + "types": "./build/esm/components/DataTypePopup/index.d.ts", + "default": "./build/esm/components/DataTypePopup/index.js" + }, + "require": { + "types": "./build/cjs/components/DataTypePopup/index.d.ts", + "default": "./build/cjs/components/DataTypePopup/index.js" + } + }, "./components/DataTable": { "import": { "types": "./build/esm/components/DataTable/index.d.ts", diff --git a/src/components/DataTypePopup/DataTypePopup.scss b/src/components/DataTypePopup/DataTypePopup.scss new file mode 100644 index 0000000..7e196ec --- /dev/null +++ b/src/components/DataTypePopup/DataTypePopup.scss @@ -0,0 +1,25 @@ +.qp-data-type-popup { + min-width: 0; + border-radius: var(--g-border-radius-xs); + + &_interactive { + cursor: pointer; + } + + &: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..9a13b23 --- /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/dataTypes'; + +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..64bf97a --- /dev/null +++ b/src/components/DataTypePopup/DataTypePopup.tsx @@ -0,0 +1,48 @@ +import React, {useState} 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) { + const [open, setOpen] = useState(false); + if (!type) { + return {children}; + } + const label = formatType(type); + return ( + + + + } + > + setOpen(!open)} + > + {children} + {!hideIcon && } + + + ); +} diff --git a/src/components/DataTypePopup/README.md b/src/components/DataTypePopup/README.md new file mode 100644 index 0000000..60408af --- /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 shared +`ParameterType` descriptor from `src/types/dataTypes` 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. QueryParameters 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..850ab46 --- /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/dataTypes'; +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/index.ts b/src/components/index.ts index 1f264d9..051ec1e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -96,4 +96,6 @@ export * from './AttachmentList'; export * from './AttachmentListPlaceholder'; export {QueryGraph} from './QueryGraph'; export type {QueryGraphProps} from './QueryGraph'; +export {DataTypePopup} from './DataTypePopup'; +export type {DataTypePopupProps} from './DataTypePopup'; export * from './MonacoDiffEditor'; diff --git a/src/helpers/dataTypes.ts b/src/helpers/dataTypes.ts new file mode 100644 index 0000000..0108dcf --- /dev/null +++ b/src/helpers/dataTypes.ts @@ -0,0 +1,15 @@ +import type {ParameterType} from '../types/dataTypes'; + +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/helpers/parameterDate.ts b/src/helpers/parameterDate.ts new file mode 100644 index 0000000..56b7ac7 --- /dev/null +++ b/src/helpers/parameterDate.ts @@ -0,0 +1,39 @@ +function validateInterval(value: string): boolean { + const match = + /^-?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.(\d{1,6}))?S)?)?$/.exec( + value, + ); + return Boolean(match && match.slice(1).some(Boolean) && !value.endsWith('T')); +} + +/** Validate UI date text; engine-specific date and interval ranges belong to the application. */ +export function validateDateValue(value: string, kind: string): boolean { + if (kind === 'Interval' || kind === 'Interval64') return validateInterval(value); + if (!/^(Date(?:32)?|Datetime(?:64)?|Timestamp(?:64)?)$/.test(kind)) return false; + + const extended = /(?:32|64)$/.test(kind); + const yearPattern = extended ? '[+-]?\\d{1,6}' : '\\d{4}'; + const dateOnly = /^Date(?:32)?$/.test(kind); + const fraction = kind.startsWith('Timestamp') ? '(?:\\.\\d{1,6})?' : ''; + const pattern = new RegExp( + `^(${yearPattern})-(\\d{2})-(\\d{2})${dateOnly ? '' : `T(\\d{2}):(\\d{2}):(\\d{2})${fraction}Z`}$`, + ); + const match = pattern.exec(value); + if (!match) return false; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (year === 0 || month < 1 || month > 12) return false; + + // There is no year zero in the input; use astronomical years for leap-year arithmetic. + const calendarYear = year < 0 ? year + 1 : year; + const leapYear = + calendarYear % 4 === 0 && (calendarYear % 100 !== 0 || calendarYear % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return ( + day >= 1 && + day <= daysInMonth[month - 1] && + (dateOnly || (Number(match[4]) <= 23 && Number(match[5]) <= 59 && Number(match[6]) <= 59)) + ); +} diff --git a/src/helpers/parameterValidation.ts b/src/helpers/parameterValidation.ts new file mode 100644 index 0000000..5dcc218 --- /dev/null +++ b/src/helpers/parameterValidation.ts @@ -0,0 +1,51 @@ +const decimalPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i; +const specialNumberPattern = /^[+-]?(?:inf|nan)$/i; + +function validateInteger(value: string, name: string, bits: bigint) { + const unsigned = name.startsWith('Uint'); + if (!/^[+-]?\d+$/.test(value)) return false; + const min = unsigned ? 0n : -(2n ** (bits - 1n)); + const max = unsigned ? 2n ** bits - 1n : 2n ** (bits - 1n) - 1n; + const integer = BigInt(value); + return integer >= min && integer <= max; +} + +function validateDecimal(value: string, params?: readonly unknown[]) { + if (specialNumberPattern.test(value)) return true; + const match = /^[+-]?(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:e([+-]?\d+))?$/i.exec(value); + if (!match) return false; + const [precision, scale] = params?.map(Number) || []; + if (!Number.isInteger(precision) || !Number.isInteger(scale)) return true; + const fraction = match[2] || match[3] || ''; + const digits = ((match[1] || '') + fraction).replace(/^0+/, ''); + if (!digits) return true; + const significantDigits = digits.replace(/0+$/, ''); + const trailingZeros = digits.length - significantDigits.length; + // Count decimal places without materializing the exponent or losing numeric precision. + const decimalPlaces = BigInt(fraction.length - trailingZeros) - BigInt(match[4] || '0'); + const integerDigits = BigInt(significantDigits.length) - decimalPlaces; + return integerDigits <= BigInt(precision - scale) && decimalPlaces <= BigInt(scale); +} + +/** Validates numeric parameter strings without losing integer precision through JS Number. */ +export function validateNumericValue( + value: unknown, + name: string, + params?: readonly unknown[], +): boolean { + if (typeof value !== 'string') return false; + const integer = /^(?:Uint|Int)(8|16|32|64)$/.exec(name); + if (integer) return validateInteger(value, name, BigInt(integer[1])); + switch (name) { + case 'Decimal': + return validateDecimal(value, params); + case 'Float': + case 'Double': + return specialNumberPattern.test(value) || decimalPattern.test(value); + case 'DyNumber': + case 'Number': + return decimalPattern.test(value); + default: + return false; + } +} diff --git a/src/index.ts b/src/index.ts index 8e94f8e..693a471 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,8 @@ export * from './types/errorTree'; export * from './types/queryStatistics'; export * from './types/queryResults'; export * from './types/queryGraph'; +export * from './types/dataTypes'; +export * from './types/dataTypePopup'; export * from './types/queryTimeline'; export * from './types/queryExecutionPanel'; export * from './types/queriesSidebar'; diff --git a/src/modules/QueryParameters/QueryParameters.tsx b/src/modules/QueryParameters/QueryParameters.tsx index 7df5cd7..0f75e1e 100644 --- a/src/modules/QueryParameters/QueryParameters.tsx +++ b/src/modules/QueryParameters/QueryParameters.tsx @@ -47,7 +47,6 @@ export function QueryParameters({ const [session, setSession] = useState(null); const [tab, setTab] = useState('current'); const [search, setSearch] = useState(''); - const [attempted, setAttempted] = useState(false); const [pending, setPending] = useState<'save' | 'update' | null>(null); const [failure, setFailure] = useState<'save' | 'update' | null>(null); const [confirmUpdate, setConfirmUpdate] = useState(false); @@ -65,7 +64,9 @@ export function QueryParameters({ const parameters = session?.draft ?? value; const current = parameters.filter((parameter) => !parameter.deleted); const deleted = parameters.filter((parameter) => parameter.deleted); - const validation = attempted ? validateParameters(parameters) : {}; + const validation = editing ? validateParameters(parameters) : {}; + const hasValidationErrors = Object.keys(validation).length > 0; + const invalidDraft = hasValidationErrors || pendingValues.size > 0; const query = search.trim().toLocaleLowerCase(); const matches = (parameter: QueryParameter) => `${parameter.name} ${formatType(parameter.type)}`.toLocaleLowerCase().includes(query); @@ -102,7 +103,6 @@ export function QueryParameters({ const begin = () => { if (locked) return; setSession({baseline: serialized, original: clone(value), draft: clone(value)}); - setAttempted(false); setFailure(null); setSearch(''); setTab('current'); @@ -111,7 +111,6 @@ export function QueryParameters({ const cancel = () => { if (locked) return; setSession(null); - setAttempted(false); setFailure(null); setPendingValues(new Set()); setFocusTarget('edit'); @@ -170,7 +169,6 @@ export function QueryParameters({ await onChange(clone(next)); if (mounted.current) { setSession(null); - setAttempted(false); setPendingValues(new Set()); setFocusTarget('edit'); } @@ -182,14 +180,7 @@ export function QueryParameters({ } }; const save = () => { - if (!session || locked || conflict) return; - setAttempted(true); - const nextErrors = validateParameters(session.draft); - if (Object.keys(nextErrors).length || pendingValues.size) { - setTab('current'); - setSearch(''); - return; - } + if (!session || locked || conflict || invalidDraft) return; commit(session.draft); }; const update = async () => { @@ -202,7 +193,6 @@ export function QueryParameters({ await onUpdate(); if (mounted.current) { setSession(null); - setAttempted(false); setPendingValues(new Set()); setFocusTarget('edit'); } @@ -214,6 +204,18 @@ export function QueryParameters({ } }; + const showParametersAction = + query || tab !== 'current' ? ( + + ) : undefined; + return ( )} - {attempted && Object.keys(validation).length > 0 && ( + {hasValidationErrors && (
- +
)} {pendingValues.size > 0 && ( - + )}